Merge pull request #3217 from bluesky-social/samuel/alf-login
Use ALF for login & signup flowzio/stable
commit
c649ee1afa
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 296 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 871 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 327 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 373 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z"/></svg>
|
After Width: | Height: | Size: 768 B |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.73.0",
|
"version": "1.74.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {web, native} from '#/alf/util/platform'
|
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
|
import {native, web} from '#/alf/util/platform'
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
/*
|
/*
|
||||||
|
@ -157,6 +158,12 @@ export const atoms = {
|
||||||
align_end: {
|
align_end: {
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
|
align_baseline: {
|
||||||
|
alignItems: 'baseline',
|
||||||
|
},
|
||||||
|
align_stretch: {
|
||||||
|
alignItems: 'stretch',
|
||||||
|
},
|
||||||
self_auto: {
|
self_auto: {
|
||||||
alignSelf: 'auto',
|
alignSelf: 'auto',
|
||||||
},
|
},
|
||||||
|
@ -247,10 +254,10 @@ export const atoms = {
|
||||||
fontWeight: tokens.fontWeight.normal,
|
fontWeight: tokens.fontWeight.normal,
|
||||||
},
|
},
|
||||||
font_semibold: {
|
font_semibold: {
|
||||||
fontWeight: '500',
|
fontWeight: tokens.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
font_bold: {
|
font_bold: {
|
||||||
fontWeight: tokens.fontWeight.semibold,
|
fontWeight: tokens.fontWeight.bold,
|
||||||
},
|
},
|
||||||
italic: {
|
italic: {
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
@ -300,6 +307,9 @@ export const atoms = {
|
||||||
/*
|
/*
|
||||||
* Padding
|
* Padding
|
||||||
*/
|
*/
|
||||||
|
p_0: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
p_2xs: {
|
p_2xs: {
|
||||||
padding: tokens.space._2xs,
|
padding: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -330,6 +340,10 @@ export const atoms = {
|
||||||
p_5xl: {
|
p_5xl: {
|
||||||
padding: tokens.space._5xl,
|
padding: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
px_0: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
},
|
||||||
px_2xs: {
|
px_2xs: {
|
||||||
paddingLeft: tokens.space._2xs,
|
paddingLeft: tokens.space._2xs,
|
||||||
paddingRight: tokens.space._2xs,
|
paddingRight: tokens.space._2xs,
|
||||||
|
@ -370,6 +384,10 @@ export const atoms = {
|
||||||
paddingLeft: tokens.space._5xl,
|
paddingLeft: tokens.space._5xl,
|
||||||
paddingRight: tokens.space._5xl,
|
paddingRight: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
py_0: {
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
py_2xs: {
|
py_2xs: {
|
||||||
paddingTop: tokens.space._2xs,
|
paddingTop: tokens.space._2xs,
|
||||||
paddingBottom: tokens.space._2xs,
|
paddingBottom: tokens.space._2xs,
|
||||||
|
@ -410,6 +428,9 @@ export const atoms = {
|
||||||
paddingTop: tokens.space._5xl,
|
paddingTop: tokens.space._5xl,
|
||||||
paddingBottom: tokens.space._5xl,
|
paddingBottom: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
pt_0: {
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
pt_2xs: {
|
pt_2xs: {
|
||||||
paddingTop: tokens.space._2xs,
|
paddingTop: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -440,6 +461,9 @@ export const atoms = {
|
||||||
pt_5xl: {
|
pt_5xl: {
|
||||||
paddingTop: tokens.space._5xl,
|
paddingTop: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
pb_0: {
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
pb_2xs: {
|
pb_2xs: {
|
||||||
paddingBottom: tokens.space._2xs,
|
paddingBottom: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -470,6 +494,9 @@ export const atoms = {
|
||||||
pb_5xl: {
|
pb_5xl: {
|
||||||
paddingBottom: tokens.space._5xl,
|
paddingBottom: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
pl_0: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
},
|
||||||
pl_2xs: {
|
pl_2xs: {
|
||||||
paddingLeft: tokens.space._2xs,
|
paddingLeft: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -500,6 +527,9 @@ export const atoms = {
|
||||||
pl_5xl: {
|
pl_5xl: {
|
||||||
paddingLeft: tokens.space._5xl,
|
paddingLeft: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
pr_0: {
|
||||||
|
paddingRight: 0,
|
||||||
|
},
|
||||||
pr_2xs: {
|
pr_2xs: {
|
||||||
paddingRight: tokens.space._2xs,
|
paddingRight: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -534,9 +564,8 @@ export const atoms = {
|
||||||
/*
|
/*
|
||||||
* Margin
|
* Margin
|
||||||
*/
|
*/
|
||||||
mx_auto: {
|
m_0: {
|
||||||
marginLeft: 'auto',
|
margin: 0,
|
||||||
marginRight: 'auto',
|
|
||||||
},
|
},
|
||||||
m_2xs: {
|
m_2xs: {
|
||||||
margin: tokens.space._2xs,
|
margin: tokens.space._2xs,
|
||||||
|
@ -568,6 +597,13 @@ export const atoms = {
|
||||||
m_5xl: {
|
m_5xl: {
|
||||||
margin: tokens.space._5xl,
|
margin: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
m_auto: {
|
||||||
|
margin: 'auto',
|
||||||
|
},
|
||||||
|
mx_0: {
|
||||||
|
marginLeft: 0,
|
||||||
|
marginRight: 0,
|
||||||
|
},
|
||||||
mx_2xs: {
|
mx_2xs: {
|
||||||
marginLeft: tokens.space._2xs,
|
marginLeft: tokens.space._2xs,
|
||||||
marginRight: tokens.space._2xs,
|
marginRight: tokens.space._2xs,
|
||||||
|
@ -608,6 +644,14 @@ export const atoms = {
|
||||||
marginLeft: tokens.space._5xl,
|
marginLeft: tokens.space._5xl,
|
||||||
marginRight: tokens.space._5xl,
|
marginRight: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
mx_auto: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
|
my_0: {
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
my_2xs: {
|
my_2xs: {
|
||||||
marginTop: tokens.space._2xs,
|
marginTop: tokens.space._2xs,
|
||||||
marginBottom: tokens.space._2xs,
|
marginBottom: tokens.space._2xs,
|
||||||
|
@ -648,6 +692,13 @@ export const atoms = {
|
||||||
marginTop: tokens.space._5xl,
|
marginTop: tokens.space._5xl,
|
||||||
marginBottom: tokens.space._5xl,
|
marginBottom: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
my_auto: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
marginBottom: 'auto',
|
||||||
|
},
|
||||||
|
mt_0: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
mt_2xs: {
|
mt_2xs: {
|
||||||
marginTop: tokens.space._2xs,
|
marginTop: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -678,6 +729,12 @@ export const atoms = {
|
||||||
mt_5xl: {
|
mt_5xl: {
|
||||||
marginTop: tokens.space._5xl,
|
marginTop: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
mt_auto: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
},
|
||||||
|
mb_0: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
mb_2xs: {
|
mb_2xs: {
|
||||||
marginBottom: tokens.space._2xs,
|
marginBottom: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -708,6 +765,12 @@ export const atoms = {
|
||||||
mb_5xl: {
|
mb_5xl: {
|
||||||
marginBottom: tokens.space._5xl,
|
marginBottom: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
mb_auto: {
|
||||||
|
marginBottom: 'auto',
|
||||||
|
},
|
||||||
|
ml_0: {
|
||||||
|
marginLeft: 0,
|
||||||
|
},
|
||||||
ml_2xs: {
|
ml_2xs: {
|
||||||
marginLeft: tokens.space._2xs,
|
marginLeft: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -738,6 +801,12 @@ export const atoms = {
|
||||||
ml_5xl: {
|
ml_5xl: {
|
||||||
marginLeft: tokens.space._5xl,
|
marginLeft: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
ml_auto: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
mr_0: {
|
||||||
|
marginRight: 0,
|
||||||
|
},
|
||||||
mr_2xs: {
|
mr_2xs: {
|
||||||
marginRight: tokens.space._2xs,
|
marginRight: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
@ -768,4 +837,7 @@ export const atoms = {
|
||||||
mr_5xl: {
|
mr_5xl: {
|
||||||
marginRight: tokens.space._5xl,
|
marginRight: tokens.space._5xl,
|
||||||
},
|
},
|
||||||
|
mr_auto: {
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import {
|
import {
|
||||||
BLUE_HUE,
|
BLUE_HUE,
|
||||||
RED_HUE,
|
|
||||||
GREEN_HUE,
|
|
||||||
generateScale,
|
generateScale,
|
||||||
|
GREEN_HUE,
|
||||||
|
RED_HUE,
|
||||||
} from '#/alf/util/colorGeneration'
|
} from '#/alf/util/colorGeneration'
|
||||||
|
|
||||||
export const scale = generateScale(6, 100)
|
export const scale = generateScale(6, 100)
|
||||||
|
@ -116,8 +116,8 @@ export const borderRadius = {
|
||||||
|
|
||||||
export const fontWeight = {
|
export const fontWeight = {
|
||||||
normal: '400',
|
normal: '400',
|
||||||
semibold: '600',
|
semibold: '500',
|
||||||
bold: '900',
|
bold: '600',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const gradients = {
|
export const gradients = {
|
||||||
|
|
|
@ -1,19 +1,11 @@
|
||||||
import React from 'react'
|
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 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 * as utils from '#/components/forms/DateField/utils'
|
||||||
export const Label = TextField.Label
|
export const Label = TextField.Label
|
||||||
|
@ -24,18 +16,10 @@ export function DateField({
|
||||||
label,
|
label,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
testID,
|
testID,
|
||||||
|
accessibilityHint,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [open, setOpen] = React.useState(false)
|
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(
|
const onChangeInternal = React.useCallback(
|
||||||
(date: Date) => {
|
(date: Date) => {
|
||||||
|
@ -47,50 +31,29 @@ export function DateField({
|
||||||
[onChangeDate, setOpen],
|
[onChangeDate, setOpen],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onPress = React.useCallback(() => {
|
||||||
|
setOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const onCancel = React.useCallback(() => {
|
const onCancel = React.useCallback(() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[atoms.relative, atoms.w_full]}>
|
<>
|
||||||
<Pressable
|
<DateFieldButton
|
||||||
aria-label={label}
|
label={label}
|
||||||
accessibilityLabel={label}
|
value={value}
|
||||||
accessibilityHint={undefined}
|
onPress={onPress}
|
||||||
onPress={() => setOpen(true)}
|
isInvalid={isInvalid}
|
||||||
onPressIn={onPressIn}
|
accessibilityHint={accessibilityHint}
|
||||||
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 : {},
|
|
||||||
]}>
|
|
||||||
<TextField.Icon icon={CalendarDays} />
|
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
|
|
||||||
{localizeDate(value)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
modal={isAndroid}
|
modal
|
||||||
open={isAndroid}
|
open
|
||||||
|
timeZoneOffsetInMinutes={0}
|
||||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||||
date={new Date(value)}
|
date={new Date(value)}
|
||||||
onConfirm={onChangeInternal}
|
onConfirm={onChangeInternal}
|
||||||
|
@ -99,9 +62,9 @@ export function DateField({
|
||||||
testID={`${testID}-datepicker`}
|
testID={`${testID}-datepicker`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityHint={undefined}
|
accessibilityHint={accessibilityHint}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
<View
|
||||||
|
style={[a.relative, a.w_full]}
|
||||||
|
{...web({
|
||||||
|
onMouseOver: onHoverIn,
|
||||||
|
onMouseOut: onHoverOut,
|
||||||
|
})}>
|
||||||
|
<Pressable
|
||||||
|
aria-label={label}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={accessibilityHint}
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingLeft: 14,
|
||||||
|
paddingRight: 14,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
android({
|
||||||
|
minHeight: 57.5,
|
||||||
|
}),
|
||||||
|
a.flex_row,
|
||||||
|
a.flex_1,
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.align_center,
|
||||||
|
hovered ? chromeHover : {},
|
||||||
|
focused || pressed ? chromeFocus : {},
|
||||||
|
isInvalid || isInvalid ? chromeError : {},
|
||||||
|
(isInvalid || isInvalid) && (hovered || focused)
|
||||||
|
? chromeErrorHover
|
||||||
|
: {},
|
||||||
|
]}>
|
||||||
|
<TextField.Icon icon={CalendarDays} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.pl_xs,
|
||||||
|
t.atoms.text,
|
||||||
|
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||||
|
]}>
|
||||||
|
{localizeDate(value)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,11 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
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 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 * as utils from '#/components/forms/DateField/utils'
|
||||||
export const Label = TextField.Label
|
export const Label = TextField.Label
|
||||||
|
@ -22,8 +27,12 @@ export function DateField({
|
||||||
onChangeDate,
|
onChangeDate,
|
||||||
testID,
|
testID,
|
||||||
label,
|
label,
|
||||||
|
isInvalid,
|
||||||
|
accessibilityHint,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
const onChangeInternal = React.useCallback(
|
const onChangeInternal = React.useCallback(
|
||||||
(date: Date | undefined) => {
|
(date: Date | undefined) => {
|
||||||
|
@ -36,17 +45,44 @@ export function DateField({
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[atoms.relative, atoms.w_full]}>
|
<>
|
||||||
<DatePicker
|
<DateFieldButton
|
||||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
label={label}
|
||||||
date={new Date(value)}
|
value={value}
|
||||||
onDateChange={onChangeInternal}
|
onPress={control.open}
|
||||||
mode="date"
|
isInvalid={isInvalid}
|
||||||
testID={`${testID}-datepicker`}
|
accessibilityHint={accessibilityHint}
|
||||||
aria-label={label}
|
|
||||||
accessibilityLabel={label}
|
|
||||||
accessibilityHint={undefined}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
<Dialog.Outer control={control} testID={testID}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.Inner label={label}>
|
||||||
|
<View style={a.gap_lg}>
|
||||||
|
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||||
|
<DatePicker
|
||||||
|
timeZoneOffsetInMinutes={0}
|
||||||
|
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||||
|
date={new Date(value)}
|
||||||
|
onDateChange={onChangeInternal}
|
||||||
|
mode="date"
|
||||||
|
testID={`${testID}-datepicker`}
|
||||||
|
aria-label={label}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={accessibilityHint}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Done`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Inner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
|
import {StyleSheet, TextInput, TextInputProps} from 'react-native'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import {unstable_createElement} from 'react-native-web'
|
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 {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 * as utils from '#/components/forms/DateField/utils'
|
||||||
export const Label = TextField.Label
|
export const Label = TextField.Label
|
||||||
|
@ -37,6 +38,7 @@ export function DateField({
|
||||||
label,
|
label,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
testID,
|
testID,
|
||||||
|
accessibilityHint,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const handleOnChange = React.useCallback(
|
const handleOnChange = React.useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
|
@ -52,12 +54,14 @@ export function DateField({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField.Root isInvalid={isInvalid}>
|
<TextField.Root isInvalid={isInvalid}>
|
||||||
|
<TextField.Icon icon={CalendarDays} />
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
label={label}
|
label={label}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
onChangeText={() => {}}
|
onChangeText={() => {}}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
accessibilityHint={accessibilityHint}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,4 +4,5 @@ export type DateFieldProps = {
|
||||||
label: string
|
label: string
|
||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
testID?: string
|
testID?: string
|
||||||
|
accessibilityHint?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{backgroundColor: t.palette.negative_400},
|
||||||
|
a.flex_row,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_md,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Warning fill={t.palette.white} size="md" />
|
||||||
|
<View>
|
||||||
|
<Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<ServerInputDialog
|
||||||
|
control={serverInputControl}
|
||||||
|
onSelect={onSelectServiceUrl}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={toNiceDomain(serviceUrl)}
|
||||||
|
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.px_md,
|
||||||
|
a.pr_sm,
|
||||||
|
a.gap_xs,
|
||||||
|
{paddingVertical: isAndroid ? 14 : 9},
|
||||||
|
]}
|
||||||
|
onPress={onPressSelectService}>
|
||||||
|
{({hovered, pressed}) => {
|
||||||
|
const interacted = hovered || pressed
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={a.pr_xs}>
|
||||||
|
<Globe
|
||||||
|
size="md"
|
||||||
|
fill={
|
||||||
|
interacted ? t.palette.contrast_800 : t.palette.contrast_500
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
interacted
|
||||||
|
? t.atoms.bg_contrast_300
|
||||||
|
: t.atoms.bg_contrast_100,
|
||||||
|
{marginLeft: 'auto', padding: 6},
|
||||||
|
]}>
|
||||||
|
<Pencil
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
color: interacted
|
||||||
|
? t.palette.contrast_800
|
||||||
|
: t.palette.contrast_500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import {useTheme, atoms as a, web, android} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
inputRef: React.RefObject<TextInput> | null
|
inputRef: React.RefObject<TextInput> | null
|
||||||
|
@ -125,9 +126,10 @@ export function useSharedInputStyles() {
|
||||||
|
|
||||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value?: string
|
||||||
onChangeText: (value: string) => void
|
onChangeText?: (value: string) => void
|
||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
|
inputRef?: React.RefObject<TextInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInput(Component: typeof TextInput) {
|
export function createInput(Component: typeof TextInput) {
|
||||||
|
@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
|
inputRef,
|
||||||
...rest
|
...rest
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Component
|
<Component
|
||||||
accessibilityHint={undefined}
|
accessibilityHint={undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
ref={ctx.inputRef}
|
ref={refs}
|
||||||
value={value}
|
value={value}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
onFocus={ctx.onFocus}
|
onFocus={ctx.onFocus}
|
||||||
onBlur={ctx.onBlur}
|
onBlur={ctx.onBlur}
|
||||||
placeholder={placeholder || label}
|
placeholder={placeholder || label}
|
||||||
placeholderTextColor={t.palette.contrast_500}
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
|
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||||
hitSlop={HITSLOP_20}
|
hitSlop={HITSLOP_20}
|
||||||
style={[
|
style={[
|
||||||
a.relative,
|
a.relative,
|
||||||
|
@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||||
<Comp
|
<Comp
|
||||||
size="md"
|
size="md"
|
||||||
style={[
|
style={[
|
||||||
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
|
||||||
ctx.hovered ? hover : {},
|
ctx.hovered ? hover : {},
|
||||||
ctx.focused ? focus : {},
|
ctx.focused ? focus : {},
|
||||||
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
|
||||||
|
})
|
|
@ -27,6 +27,7 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
|
||||||
|
|
||||||
export interface IsValidHandle {
|
export interface IsValidHandle {
|
||||||
handleChars: boolean
|
handleChars: boolean
|
||||||
|
hyphenStartOrEnd: boolean
|
||||||
frontLength: boolean
|
frontLength: boolean
|
||||||
totalLength: boolean
|
totalLength: boolean
|
||||||
overall: boolean
|
overall: boolean
|
||||||
|
@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
|
||||||
const results = {
|
const results = {
|
||||||
handleChars:
|
handleChars:
|
||||||
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
||||||
|
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
|
||||||
frontLength: str.length >= 3,
|
frontLength: str.length >= 3,
|
||||||
totalLength: fullHandle.length <= 253,
|
totalLength: fullHandle.length <= 253,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
<Button
|
||||||
|
testID={`chooseAccountBtn-${account.handle}`}
|
||||||
|
key={account.did}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onPress={onPress}
|
||||||
|
label={
|
||||||
|
isCurrentAccount
|
||||||
|
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||||
|
: _(msg`Sign in as ${account.handle}`)
|
||||||
|
}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
{height: 48},
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={a.p_md}>
|
||||||
|
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||||
|
</View>
|
||||||
|
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||||
|
<Text style={[a.font_bold]}>
|
||||||
|
{profile?.displayName || account.handle}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||||
|
</Text>
|
||||||
|
{isCurrentAccount ? (
|
||||||
|
<Check size="sm" style={[{color: colors.green3}, a.mr_md]} />
|
||||||
|
) : (
|
||||||
|
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<FormContainer
|
||||||
|
testID="chooseAccountForm"
|
||||||
|
title={<Trans>Select account</Trans>}>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Sign in as...</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
{accounts.map(account => (
|
||||||
|
<React.Fragment key={account.did}>
|
||||||
|
<AccountItem
|
||||||
|
account={account}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isCurrentAccount={account.did === currentAccount?.did}
|
||||||
|
/>
|
||||||
|
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
testID="chooseNewAccountBtn"
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onPress={() => onSelectAccount(undefined)}
|
||||||
|
label={_(msg`Login to account that is not listed`)}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
{height: 48},
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.align_baseline,
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.py_sm,
|
||||||
|
{paddingLeft: 48},
|
||||||
|
]}>
|
||||||
|
<Trans>Other account</Trans>
|
||||||
|
</Text>
|
||||||
|
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Back`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressBack}>
|
||||||
|
{_(msg`Back`)}
|
||||||
|
</Button>
|
||||||
|
<View style={[a.flex_1]} />
|
||||||
|
</View>
|
||||||
|
</FormContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<boolean>(false)
|
||||||
|
const [email, setEmail] = useState<string>('')
|
||||||
|
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 (
|
||||||
|
<FormContainer
|
||||||
|
testID="forgotPasswordForm"
|
||||||
|
title={<Trans>Reset password</Trans>}>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Hosting provider</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<HostingProvider
|
||||||
|
serviceUrl={serviceUrl}
|
||||||
|
onSelectServiceUrl={setServiceUrl}
|
||||||
|
onOpenDialog={onPressSelectService}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Email address</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={At} />
|
||||||
|
<TextField.Input
|
||||||
|
testID="forgotPasswordEmail"
|
||||||
|
label={_(msg`Enter your email address`)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
editable={!isProcessing}
|
||||||
|
accessibilityHint={_(msg`Sets email for password reset`)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
Enter the email you used to create your account. We'll send you a
|
||||||
|
"reset code" so you can set a new password.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<FormError error={error} />
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Back`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressBack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<View style={a.flex_1} />
|
||||||
|
{!serviceDescription || isProcessing ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Next`)}
|
||||||
|
variant="solid"
|
||||||
|
color={'primary'}
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressNext}
|
||||||
|
disabled={!email}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!serviceDescription || isProcessing ? (
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||||
|
<Trans>Processing...</Trans>
|
||||||
|
</Text>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
a.border_t,
|
||||||
|
a.pt_2xl,
|
||||||
|
a.mt_md,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_center,
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
testID="skipSendEmailButton"
|
||||||
|
onPress={onEmailSent}
|
||||||
|
label={_(msg`Go to next`)}
|
||||||
|
accessibilityHint={_(msg`Navigates to the next screen`)}
|
||||||
|
size="medium"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Already have a code?</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</FormContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<ViewStyle>
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
testID={testID}
|
||||||
|
style={[styles.maxHeight, contentContainerStyle]}
|
||||||
|
keyboardShouldPersistTaps="handled">
|
||||||
|
<View
|
||||||
|
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.pt_md], style]}>
|
||||||
|
{title && !gtMobile && (
|
||||||
|
<Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
maxHeight: {
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
maxHeight: isWeb ? '100vh' : undefined,
|
||||||
|
height: !isWeb ? '100%' : undefined,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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<boolean>(false)
|
||||||
|
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||||
|
const [password, setPassword] = useState<string>('')
|
||||||
|
const passwordInputRef = useRef<TextInput>(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 (
|
||||||
|
<FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Hosting provider</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<HostingProvider
|
||||||
|
serviceUrl={serviceUrl}
|
||||||
|
onSelectServiceUrl={setServiceUrl}
|
||||||
|
onOpenDialog={onPressSelectService}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Account</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={At} />
|
||||||
|
<TextField.Input
|
||||||
|
testID="loginUsernameInput"
|
||||||
|
label={_(msg`Username or email address`)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="username"
|
||||||
|
returnKeyType="next"
|
||||||
|
textContentType="username"
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
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`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Lock} />
|
||||||
|
<TextField.Input
|
||||||
|
testID="loginPasswordInput"
|
||||||
|
inputRef={passwordInputRef}
|
||||||
|
label={_(msg`Password`)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="password"
|
||||||
|
returnKeyType="done"
|
||||||
|
enablesReturnKeyAutomatically={true}
|
||||||
|
secureTextEntry={true}
|
||||||
|
textContentType="password"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
onSubmitEditing={onPressNext}
|
||||||
|
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||||
|
editable={!isProcessing}
|
||||||
|
accessibilityHint={
|
||||||
|
identifier === ''
|
||||||
|
? _(msg`Input your password`)
|
||||||
|
: _(msg`Input the password tied to ${identifier}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
testID="forgotPasswordButton"
|
||||||
|
onPress={onPressForgotPassword}
|
||||||
|
label={_(msg`Forgot password?`)}
|
||||||
|
accessibilityHint={_(msg`Opens password reset form`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
// t.atoms.bg_contrast_100,
|
||||||
|
{marginLeft: 'auto', left: 6, padding: 6},
|
||||||
|
a.z_10,
|
||||||
|
]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Forgot?</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<FormError error={error} />
|
||||||
|
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Back`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressBack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<View style={a.flex_1} />
|
||||||
|
{!serviceDescription && error ? (
|
||||||
|
<Button
|
||||||
|
testID="loginRetryButton"
|
||||||
|
label={_(msg`Retry`)}
|
||||||
|
accessibilityHint={_(msg`Retries login`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressRetryConnect}>
|
||||||
|
{_(msg`Retry`)}
|
||||||
|
</Button>
|
||||||
|
) : !serviceDescription ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||||
|
<Trans>Connecting...</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : isReady ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Next`)}
|
||||||
|
accessibilityHint={_(msg`Navigates to the next screen`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressNext}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
</FormContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<FormContainer
|
||||||
|
testID="passwordUpdatedForm"
|
||||||
|
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
|
||||||
|
<Text style={[a.text_3xl, a.font_bold, a.text_center]}>
|
||||||
|
<Trans>Password updated!</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
|
||||||
|
<Trans>You can now sign in with your new password.</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.justify_center]}>
|
||||||
|
<Button
|
||||||
|
onPress={onPressNext}
|
||||||
|
label={_(msg`Close alert`)}
|
||||||
|
accessibilityHint={_(msg`Closes password update alert`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Okay</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</FormContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
export function ScreenTransition({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export {Fragment as ScreenTransition} from 'react'
|
|
@ -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<boolean>(false)
|
||||||
|
const [resetCode, setResetCode] = useState<string>('')
|
||||||
|
const [password, setPassword] = useState<string>('')
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<FormContainer
|
||||||
|
testID="setNewPasswordForm"
|
||||||
|
title={<Trans>Set new password</Trans>}>
|
||||||
|
<Text style={[a.leading_snug, a.mb_sm]}>
|
||||||
|
<Trans>
|
||||||
|
You will receive an email with a "reset code." Enter that code here,
|
||||||
|
then enter your new password.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<TextField.Label>Reset code</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Ticket} />
|
||||||
|
<TextField.Input
|
||||||
|
testID="resetCodeInput"
|
||||||
|
label={_(msg`Looks like XXXXX-XXXXX`)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus={true}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
value={resetCode}
|
||||||
|
onChangeText={setResetCode}
|
||||||
|
onFocus={() => setError('')}
|
||||||
|
onBlur={onBlur}
|
||||||
|
editable={!isProcessing}
|
||||||
|
accessibilityHint={_(
|
||||||
|
msg`Input code sent to your email for password reset`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<TextField.Label>New password</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Lock} />
|
||||||
|
<TextField.Input
|
||||||
|
testID="newPasswordInput"
|
||||||
|
label={_(msg`Enter a password`)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="password"
|
||||||
|
returnKeyType="done"
|
||||||
|
secureTextEntry={true}
|
||||||
|
textContentType="password"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
onSubmitEditing={onPressNext}
|
||||||
|
editable={!isProcessing}
|
||||||
|
accessibilityHint={_(msg`Input new password`)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FormError error={error} />
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.pt_lg]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Back`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressBack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<View style={a.flex_1} />
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Next`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressNext}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||||
|
<Trans>Updating...</Trans>
|
||||||
|
</Text>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
</FormContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<string>('')
|
||||||
|
const [serviceUrl, setServiceUrl] = React.useState<string>(
|
||||||
|
requestedAccount?.service || DEFAULT_SERVICE,
|
||||||
|
)
|
||||||
|
const [initialHandle, setInitialHandle] = React.useState<string>(
|
||||||
|
requestedAccount?.handle || '',
|
||||||
|
)
|
||||||
|
const [currentForm, setCurrentForm] = React.useState<Forms>(
|
||||||
|
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 = (
|
||||||
|
<LoginForm
|
||||||
|
error={error}
|
||||||
|
serviceUrl={serviceUrl}
|
||||||
|
serviceDescription={serviceDescription}
|
||||||
|
initialHandle={initialHandle}
|
||||||
|
setError={setError}
|
||||||
|
setServiceUrl={setServiceUrl}
|
||||||
|
onPressBack={() =>
|
||||||
|
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 = (
|
||||||
|
<ChooseAccountForm
|
||||||
|
onSelectAccount={onSelectAccount}
|
||||||
|
onPressBack={onPressBack}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case Forms.ForgotPassword:
|
||||||
|
title = _(msg`Forgot Password`)
|
||||||
|
description = _(msg`Let's get your password reset!`)
|
||||||
|
content = (
|
||||||
|
<ForgotPasswordForm
|
||||||
|
error={error}
|
||||||
|
serviceUrl={serviceUrl}
|
||||||
|
serviceDescription={serviceDescription}
|
||||||
|
setError={setError}
|
||||||
|
setServiceUrl={setServiceUrl}
|
||||||
|
onPressBack={() => gotoForm(Forms.Login)}
|
||||||
|
onEmailSent={() => gotoForm(Forms.SetNewPassword)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case Forms.SetNewPassword:
|
||||||
|
title = _(msg`Forgot Password`)
|
||||||
|
description = _(msg`Let's get your password reset!`)
|
||||||
|
content = (
|
||||||
|
<SetNewPasswordForm
|
||||||
|
error={error}
|
||||||
|
serviceUrl={serviceUrl}
|
||||||
|
setError={setError}
|
||||||
|
onPressBack={() => 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 = (
|
||||||
|
<PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
|
||||||
|
<LoggedOutLayout leadin="" title={title} description={description}>
|
||||||
|
<LayoutAnimationConfig skipEntering skipExiting>
|
||||||
|
<ScreenTransition key={currentForm}>{content}</ScreenTransition>
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
</LoggedOutLayout>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import {StyleSheet} from 'react-native'
|
||||||
import {WebView, WebViewNavigation} from 'react-native-webview'
|
import {WebView, WebViewNavigation} from 'react-native-webview'
|
||||||
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
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 = [
|
const ALLOWED_HOSTS = [
|
||||||
'bsky.social',
|
'bsky.social',
|
||||||
|
@ -17,24 +18,24 @@ const ALLOWED_HOSTS = [
|
||||||
export function CaptchaWebView({
|
export function CaptchaWebView({
|
||||||
url,
|
url,
|
||||||
stateParam,
|
stateParam,
|
||||||
uiState,
|
state,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string
|
||||||
stateParam: string
|
stateParam: string
|
||||||
uiState?: CreateAccountState
|
state?: SignupState
|
||||||
onSuccess: (code: string) => void
|
onSuccess: (code: string) => void
|
||||||
onError: () => void
|
onError: () => void
|
||||||
}) {
|
}) {
|
||||||
const redirectHost = React.useMemo(() => {
|
const redirectHost = React.useMemo(() => {
|
||||||
if (!uiState?.serviceUrl) return 'bsky.app'
|
if (!state?.serviceUrl) return 'bsky.app'
|
||||||
|
|
||||||
return uiState?.serviceUrl &&
|
return state?.serviceUrl &&
|
||||||
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
|
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
|
||||||
? 'staging.bsky.app'
|
? 'staging.bsky.app'
|
||||||
: 'bsky.app'
|
: 'bsky.app'
|
||||||
}, [uiState?.serviceUrl])
|
}, [state?.serviceUrl])
|
||||||
|
|
||||||
const wasSuccessful = React.useRef(false)
|
const wasSuccessful = React.useRef(false)
|
||||||
|
|
|
@ -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 (
|
||||||
|
<ScreenTransition>
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<View style={[styles.container, completed && styles.center]}>
|
||||||
|
{!completed ? (
|
||||||
|
<CaptchaWebView
|
||||||
|
url={url}
|
||||||
|
stateParam={stateParam}
|
||||||
|
state={state}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<FormError error={state.error} />
|
||||||
|
</View>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
|
@ -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<IsValidHandle>({
|
||||||
|
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 (
|
||||||
|
<ScreenTransition>
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<View>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={At} />
|
||||||
|
<TextField.Input
|
||||||
|
onChangeText={onHandleChange}
|
||||||
|
label={_(msg`Input your user handle`)}
|
||||||
|
defaultValue={state.handle}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>Your full handle will be</Trans>{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
@{createFullHandle(state.handle, state.userDomain)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
a.p_md,
|
||||||
|
a.gap_sm,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
{state.error ? (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<IsValidIcon valid={false} />
|
||||||
|
<Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
{validCheck.hyphenStartOrEnd ? (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<IsValidIcon valid={validCheck.handleChars} />
|
||||||
|
<Text style={[a.text_md, a.flex_1]}>
|
||||||
|
<Trans>Only contains letters, numbers, and hyphens</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<IsValidIcon valid={validCheck.hyphenStartOrEnd} />
|
||||||
|
<Text style={[a.text_md, a.flex_1]}>
|
||||||
|
<Trans>Doesn't begin or end with a hyphen</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<IsValidIcon
|
||||||
|
valid={validCheck.frontLength && validCheck.totalLength}
|
||||||
|
/>
|
||||||
|
{!validCheck.totalLength ? (
|
||||||
|
<Text style={[a.text_md, a.flex_1]}>
|
||||||
|
<Trans>No longer than 253 characters</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={[a.text_md, a.flex_1]}>
|
||||||
|
<Trans>At least 3 characters</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IsValidIcon({valid}: {valid: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
if (!valid) {
|
||||||
|
return <Times size="md" style={{color: t.palette.negative_500}} />
|
||||||
|
}
|
||||||
|
return <Check size="md" style={{color: t.palette.positive_700}} />
|
||||||
|
}
|
|
@ -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 <View />
|
||||||
|
}
|
||||||
|
|
||||||
|
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
||||||
|
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
||||||
|
|
||||||
|
if (!tos && !pp) {
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
|
||||||
|
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
This service has not provided terms of service or a privacy policy.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const els = []
|
||||||
|
if (tos) {
|
||||||
|
els.push(
|
||||||
|
<InlineLink key="tos" to={tos}>
|
||||||
|
{_(msg`Terms of Service`)}
|
||||||
|
</InlineLink>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pp) {
|
||||||
|
els.push(
|
||||||
|
<InlineLink key="pp" to={pp}>
|
||||||
|
{_(msg`Privacy Policy`)}
|
||||||
|
</InlineLink>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (els.length === 2) {
|
||||||
|
els.splice(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
<Text key="and" style={[t.atoms.text_contrast_medium]}>
|
||||||
|
{' '}
|
||||||
|
and{' '}
|
||||||
|
</Text>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>By creating an account you agree to the {els}.</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{under13 ? (
|
||||||
|
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
You must be 13 years of age or older to sign up.
|
||||||
|
</Text>
|
||||||
|
) : needsGuardian ? (
|
||||||
|
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
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.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validWebLink(url?: string): string | undefined {
|
||||||
|
return url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||||
|
? url
|
||||||
|
: undefined
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<ScreenTransition>
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<FormError error={state.error} />
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Hosting provider</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<HostingProvider
|
||||||
|
serviceUrl={state.serviceUrl}
|
||||||
|
onSelectServiceUrl={v =>
|
||||||
|
dispatch({type: 'setServiceUrl', value: v})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{state.isLoading ? (
|
||||||
|
<View style={[a.align_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : state.serviceDescription ? (
|
||||||
|
<>
|
||||||
|
{state.serviceDescription.inviteCodeRequired && (
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Invite code</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Ticket} />
|
||||||
|
<TextField.Input
|
||||||
|
onChangeText={value => {
|
||||||
|
dispatch({
|
||||||
|
type: 'setInviteCode',
|
||||||
|
value: value.trim(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
label={_(msg`Required for this provider`)}
|
||||||
|
defaultValue={state.inviteCode}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Envelope} />
|
||||||
|
<TextField.Input
|
||||||
|
onChangeText={value => {
|
||||||
|
dispatch({
|
||||||
|
type: 'setEmail',
|
||||||
|
value: value.trim(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
label={_(msg`Enter your email address`)}
|
||||||
|
defaultValue={state.email}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TextField.Label>
|
||||||
|
<Trans>Password</Trans>
|
||||||
|
</TextField.Label>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Lock} />
|
||||||
|
<TextField.Input
|
||||||
|
onChangeText={value => {
|
||||||
|
dispatch({
|
||||||
|
type: 'setPassword',
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
label={_(msg`Choose your password`)}
|
||||||
|
defaultValue={state.password}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<DateField.Label>
|
||||||
|
<Trans>Your birth date</Trans>
|
||||||
|
</DateField.Label>
|
||||||
|
<DateField.DateField
|
||||||
|
testID="date"
|
||||||
|
value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
|
||||||
|
onChangeDate={date => {
|
||||||
|
dispatch({
|
||||||
|
type: 'setDateOfBirth',
|
||||||
|
value: sanitizeDate(new Date(date)),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
label={_(msg`Date of birth`)}
|
||||||
|
accessibilityHint={_(msg`Select your date of birth`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Policies
|
||||||
|
serviceDescription={state.serviceDescription}
|
||||||
|
needsGuardian={!is18(state.dateOfBirth)}
|
||||||
|
under13={!is13(state.dateOfBirth)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<SignupContext.Provider value={{state, dispatch}}>
|
||||||
|
<LoggedOutLayout
|
||||||
|
leadin=""
|
||||||
|
title={_(msg`Create Account`)}
|
||||||
|
description={_(msg`We're so excited to have you join us!`)}>
|
||||||
|
<ScrollView
|
||||||
|
testID="createAccount"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
style={a.h_full}
|
||||||
|
keyboardDismissMode="on-drag">
|
||||||
|
<View style={[a.flex_1, a.px_xl, a.pt_2xl, {paddingBottom: 100}]}>
|
||||||
|
<View style={[a.gap_sm, a.pb_3xl]}>
|
||||||
|
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
|
||||||
|
{state.serviceDescription &&
|
||||||
|
!state.serviceDescription.phoneVerificationRequired
|
||||||
|
? '2'
|
||||||
|
: '3'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_3xl, a.font_bold]}>
|
||||||
|
{state.activeStep === SignupStep.INFO ? (
|
||||||
|
<Trans>Your account</Trans>
|
||||||
|
) : state.activeStep === SignupStep.HANDLE ? (
|
||||||
|
<Trans>Your user handle</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Complete the challenge</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_3xl]}>
|
||||||
|
{state.activeStep === SignupStep.INFO ? (
|
||||||
|
<StepInfo />
|
||||||
|
) : state.activeStep === SignupStep.HANDLE ? (
|
||||||
|
<StepHandle />
|
||||||
|
) : (
|
||||||
|
<StepCaptcha />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.pb_lg]}>
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onBackPress}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
{state.activeStep !== SignupStep.CAPTCHA && (
|
||||||
|
<>
|
||||||
|
{isError ? (
|
||||||
|
<Button
|
||||||
|
label="Retry"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={state.isLoading}
|
||||||
|
onPress={() => refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label="Next"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={!state.canNext || state.isLoading}
|
||||||
|
onPress={onNextPress}>
|
||||||
|
<ButtonText>Next</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View style={[a.w_full, a.py_lg]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Having trouble?</Trans>{' '}
|
||||||
|
<InlineLink to={FEEDBACK_FORM_URL({email: state.email})}>
|
||||||
|
<Trans>Contact support</Trans>
|
||||||
|
</InlineLink>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</LoggedOutLayout>
|
||||||
|
</SignupContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<SignupAction>
|
||||||
|
}
|
||||||
|
export const SignupContext = React.createContext<IContext>({} 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,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
|
@ -5,16 +5,16 @@ import {useLingui} from '@lingui/react'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {isIOS, isNative} from 'platform/detection'
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
import {Login} from 'view/com/auth/login/Login'
|
import {Login} from '#/screens/Login'
|
||||||
import {CreateAccount} from 'view/com/auth/create/CreateAccount'
|
import {Signup} from '#/screens/Signup'
|
||||||
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {s} from 'lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {SplashScreen} from './SplashScreen'
|
import {SplashScreen} from './SplashScreen'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {
|
import {
|
||||||
useLoggedOutView,
|
useLoggedOutView,
|
||||||
useLoggedOutViewControls,
|
useLoggedOutViewControls,
|
||||||
|
@ -148,7 +148,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{screenState === ScreenState.S_CreateAccount ? (
|
{screenState === ScreenState.S_CreateAccount ? (
|
||||||
<CreateAccount
|
<Signup
|
||||||
onPressBack={() =>
|
onPressBack={() =>
|
||||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Create Account`)}
|
|
||||||
description={_(msg`We're so excited to have you join us!`)}>
|
|
||||||
<ScrollView
|
|
||||||
testID="createAccount"
|
|
||||||
style={pal.view}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
keyboardDismissMode="on-drag">
|
|
||||||
<View style={styles.stepContainer}>
|
|
||||||
{uiState.step === 1 && (
|
|
||||||
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
|
|
||||||
)}
|
|
||||||
{uiState.step === 2 && (
|
|
||||||
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
|
|
||||||
)}
|
|
||||||
{uiState.step === 3 && (
|
|
||||||
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPressBackInner}
|
|
||||||
testID="backBtn"
|
|
||||||
accessibilityRole="button"
|
|
||||||
hitSlop={HITSLOP_10}>
|
|
||||||
<Text type="xl" style={pal.link}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
{uiState.canNext ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="nextBtn"
|
|
||||||
onPress={onPressNext}
|
|
||||||
accessibilityRole="button"
|
|
||||||
hitSlop={HITSLOP_10}>
|
|
||||||
{uiState.isProcessing ? (
|
|
||||||
<ActivityIndicator />
|
|
||||||
) : (
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : serviceInfoError ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="retryConnectBtn"
|
|
||||||
onPress={() => refetchServiceInfo()}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Retry`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityLiveRegion="polite"
|
|
||||||
hitSlop={HITSLOP_10}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Retry</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : serviceInfoIsFetching ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
<Text type="xl" style={[pal.text, s.pr5]}>
|
|
||||||
<Trans>Connecting...</Trans>
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.stepContainer}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
s.flexRow,
|
|
||||||
s.alignCenter,
|
|
||||||
pal.viewLight,
|
|
||||||
{borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
|
|
||||||
]}>
|
|
||||||
<Text type="md" style={pal.textLight}>
|
|
||||||
<Trans>Having trouble?</Trans>{' '}
|
|
||||||
</Text>
|
|
||||||
<TextLink
|
|
||||||
type="md"
|
|
||||||
style={pal.link}
|
|
||||||
text={_(msg`Contact support`)}
|
|
||||||
href={FEEDBACK_FORM_URL({email: uiState.email})}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
|
|
||||||
</ScrollView>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
stepContainer: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 20,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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 <View />
|
|
||||||
}
|
|
||||||
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
|
||||||
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
|
||||||
if (!tos && !pp) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.policies, {flexDirection: 'row'}]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.errorIcon,
|
|
||||||
{borderColor: pal.colors.text, marginTop: 1},
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="exclamation"
|
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
|
||||||
size={10}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[pal.textLight, s.pl5, s.flex1]}>
|
|
||||||
<Trans>
|
|
||||||
This service has not provided terms of service or a privacy policy.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const els = []
|
|
||||||
if (tos) {
|
|
||||||
els.push(
|
|
||||||
<TextLink
|
|
||||||
key="tos"
|
|
||||||
href={tos}
|
|
||||||
text={_(msg`Terms of Service`)}
|
|
||||||
style={[pal.link, s.underline]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (pp) {
|
|
||||||
els.push(
|
|
||||||
<TextLink
|
|
||||||
key="pp"
|
|
||||||
href={pp}
|
|
||||||
text={_(msg`Privacy Policy`)}
|
|
||||||
style={[pal.link, s.underline]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (els.length === 2) {
|
|
||||||
els.splice(
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
<Text key="and" style={pal.textLight}>
|
|
||||||
{' '}
|
|
||||||
and{' '}
|
|
||||||
</Text>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={styles.policies}>
|
|
||||||
<Text style={pal.textLight}>
|
|
||||||
<Trans>By creating an account you agree to the {els}.</Trans>
|
|
||||||
</Text>
|
|
||||||
{needsGuardian && (
|
|
||||||
<Text style={[pal.textLight, s.bold]}>
|
|
||||||
<Trans>
|
|
||||||
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.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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 (
|
|
||||||
<View>
|
|
||||||
<ServerInputDialog
|
|
||||||
control={serverInputControl}
|
|
||||||
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
|
|
||||||
/>
|
|
||||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
|
||||||
|
|
||||||
{uiState.error ? (
|
|
||||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
<View style={s.pb20}>
|
|
||||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
|
||||||
<Trans>Hosting provider</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
pal.borderDark,
|
|
||||||
{flexDirection: 'row', alignItems: 'center'},
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="globe"
|
|
||||||
style={[pal.textLight, {marginLeft: 14}]}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="selectServiceButton"
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
onPress={onPressSelectService}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Select service`)}
|
|
||||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
|
||||||
<Text
|
|
||||||
type="xl"
|
|
||||||
style={[
|
|
||||||
pal.text,
|
|
||||||
{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingRight: 12,
|
|
||||||
paddingLeft: 10,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{toNiceDomain(uiState.serviceUrl)}
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
pal.btn,
|
|
||||||
{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
marginHorizontal: 6,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="pen"
|
|
||||||
size={12}
|
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!uiState.serviceDescription ? (
|
|
||||||
<ActivityIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{uiState.isInviteCodeRequired && (
|
|
||||||
<View style={s.pb20}>
|
|
||||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
|
||||||
<Trans>Invite code</Trans>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
testID="inviteCodeInput"
|
|
||||||
icon="ticket"
|
|
||||||
placeholder={_(msg`Required for this provider`)}
|
|
||||||
value={uiState.inviteCode}
|
|
||||||
editable
|
|
||||||
onChange={value => 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}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!uiState.isInviteCodeRequired || uiState.inviteCode ? (
|
|
||||||
<>
|
|
||||||
<View style={s.pb20}>
|
|
||||||
<Text
|
|
||||||
type="md-medium"
|
|
||||||
style={[pal.text, s.mb2]}
|
|
||||||
nativeID="email">
|
|
||||||
<Trans>Email address</Trans>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
testID="emailInput"
|
|
||||||
icon="envelope"
|
|
||||||
placeholder={_(msg`Enter your email address`)}
|
|
||||||
value={uiState.email}
|
|
||||||
editable
|
|
||||||
onChange={value => 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}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={s.pb20}>
|
|
||||||
<Text
|
|
||||||
type="md-medium"
|
|
||||||
style={[pal.text, s.mb2]}
|
|
||||||
nativeID="password">
|
|
||||||
<Trans>Password</Trans>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
testID="passwordInput"
|
|
||||||
icon="lock"
|
|
||||||
placeholder={_(msg`Choose your password`)}
|
|
||||||
value={uiState.password}
|
|
||||||
editable
|
|
||||||
secureTextEntry
|
|
||||||
onChange={value => uiDispatch({type: 'set-password', value})}
|
|
||||||
accessibilityLabel={_(msg`Password`)}
|
|
||||||
accessibilityHint={_(msg`Set password`)}
|
|
||||||
accessibilityLabelledBy="password"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoComplete="new-password"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={s.pb20}>
|
|
||||||
<Text
|
|
||||||
type="md-medium"
|
|
||||||
style={[pal.text, s.mb2]}
|
|
||||||
nativeID="birthDate">
|
|
||||||
<Trans>Your birth date</Trans>
|
|
||||||
</Text>
|
|
||||||
<DateInput
|
|
||||||
handleAsUTC
|
|
||||||
testID="birthdayInput"
|
|
||||||
value={birthDate}
|
|
||||||
onChange={value =>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{uiState.serviceDescription && (
|
|
||||||
<Policies
|
|
||||||
serviceDescription={uiState.serviceDescription}
|
|
||||||
needsGuardian={!is18(uiState)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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'}),
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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<IsValidHandle>({
|
|
||||||
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 (
|
|
||||||
<View>
|
|
||||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
|
||||||
<View style={s.pb10}>
|
|
||||||
<View style={s.mb20}>
|
|
||||||
<TextInput
|
|
||||||
testID="handleInput"
|
|
||||||
icon="at"
|
|
||||||
placeholder="e.g. alice"
|
|
||||||
value={uiState.handle}
|
|
||||||
editable
|
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect={false}
|
|
||||||
onChange={onHandleChange}
|
|
||||||
// TODO: Add explicit text label
|
|
||||||
accessibilityLabel={_(msg`User handle`)}
|
|
||||||
accessibilityHint={_(msg`Input your user handle`)}
|
|
||||||
/>
|
|
||||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
|
||||||
<Trans>Your full handle will be</Trans>{' '}
|
|
||||||
<Text type="lg-bold" style={pal.text}>
|
|
||||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.w_full,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.border,
|
|
||||||
a.p_md,
|
|
||||||
a.gap_sm,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
|
||||||
{uiState.error ? (
|
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<IsValidIcon valid={false} />
|
|
||||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
|
||||||
{uiState.error}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<IsValidIcon valid={validCheck.handleChars} />
|
|
||||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
|
||||||
<Trans>May only contain letters and numbers</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<IsValidIcon
|
|
||||||
valid={validCheck.frontLength && validCheck.totalLength}
|
|
||||||
/>
|
|
||||||
{!validCheck.totalLength ? (
|
|
||||||
<Text style={[t.atoms.text]}>
|
|
||||||
<Trans>May not be longer than 253 characters</Trans>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text style={[t.atoms.text, a.text_md]}>
|
|
||||||
<Trans>Must be at least 3 characters</Trans>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function IsValidIcon({valid}: {valid: boolean}) {
|
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
return <Times size="md" style={{color: t.palette.negative_500}} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Check size="md" style={{color: t.palette.positive_700}} />
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<View>
|
|
||||||
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
|
|
||||||
<View style={[styles.container, completed && styles.center]}>
|
|
||||||
{!completed ? (
|
|
||||||
<CaptchaWebView
|
|
||||||
url={url}
|
|
||||||
stateParam={stateParam}
|
|
||||||
uiState={uiState}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ActivityIndicator size="large" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{uiState.error ? (
|
|
||||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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 (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View>
|
|
||||||
<Text type="lg" style={[pal.textLight]}>
|
|
||||||
{uiState.step === 3 ? (
|
|
||||||
<Trans>Last step!</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
Step {uiState.step} of {numSteps}
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={[pal.text]} type="title-xl">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID={`chooseAccountBtn-${account.handle}`}
|
|
||||||
key={account.did}
|
|
||||||
style={[pal.view, pal.border, styles.account]}
|
|
||||||
onPress={onPress}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
|
|
||||||
accessibilityHint={_(msg`Double tap to sign in`)}>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
|
||||||
<View style={s.p10}>
|
|
||||||
<UserAvatar
|
|
||||||
avatar={profile?.avatar}
|
|
||||||
size={30}
|
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.accountText}>
|
|
||||||
<Text type="lg-bold" style={pal.text}>
|
|
||||||
{profile?.displayName || account.handle}{' '}
|
|
||||||
</Text>
|
|
||||||
<Text type="lg" style={[pal.textLight]}>
|
|
||||||
{account.handle}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
{isCurrentAccount ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="check"
|
|
||||||
size={16}
|
|
||||||
style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="angle-right"
|
|
||||||
size={16}
|
|
||||||
style={[pal.text, s.mr10]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export const ChooseAccountForm = ({
|
|
||||||
onSelectAccount,
|
|
||||||
onPressBack,
|
|
||||||
}: {
|
|
||||||
onSelectAccount: (account?: SessionAccount) => void
|
|
||||||
onPressBack: () => void
|
|
||||||
}) => {
|
|
||||||
const {track, screen} = useAnalytics()
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {accounts, currentAccount} = useSession()
|
|
||||||
const {initSession} = useSessionApi()
|
|
||||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
screen('Choose Account')
|
|
||||||
}, [screen])
|
|
||||||
|
|
||||||
const onSelect = React.useCallback(
|
|
||||||
async (account: SessionAccount) => {
|
|
||||||
if (account.accessJwt) {
|
|
||||||
if (account.did === currentAccount?.did) {
|
|
||||||
setShowLoggedOut(false)
|
|
||||||
Toast.show(_(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 (
|
|
||||||
<ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
|
|
||||||
<Text
|
|
||||||
type="2xl-medium"
|
|
||||||
style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
|
|
||||||
<Trans>Sign in as...</Trans>
|
|
||||||
</Text>
|
|
||||||
{accounts.map(account => (
|
|
||||||
<AccountItem
|
|
||||||
key={account.did}
|
|
||||||
account={account}
|
|
||||||
onSelect={onSelect}
|
|
||||||
isCurrentAccount={account.did === currentAccount?.did}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="chooseNewAccountBtn"
|
|
||||||
style={[pal.view, pal.border, styles.account, styles.accountLast]}
|
|
||||||
onPress={() => onSelectAccount(undefined)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Login to account that is not listed`)}
|
|
||||||
accessibilityHint="">
|
|
||||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
|
||||||
<Text style={[styles.accountText, styles.accountTextOther]}>
|
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
<Trans>Other account</Trans>
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="angle-right"
|
|
||||||
size={16}
|
|
||||||
style={[pal.text, s.mr10]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
|
||||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<boolean>(false)
|
|
||||||
const [email, setEmail] = useState<string>('')
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<View>
|
|
||||||
<ServerInputDialog
|
|
||||||
control={serverInputControl}
|
|
||||||
onSelect={setServiceUrl}
|
|
||||||
/>
|
|
||||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
|
||||||
<Trans>Reset password</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text type="md" style={[pal.text, styles.instructions]}>
|
|
||||||
<Trans>
|
|
||||||
Enter the email you used to create your account. We'll send you a
|
|
||||||
"reset code" so you can set a new password.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
testID="forgotPasswordView"
|
|
||||||
style={[pal.borderDark, pal.view, styles.group]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="forgotPasswordSelectServiceButton"
|
|
||||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
|
|
||||||
onPress={onPressSelectService}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Hosting provider`)}
|
|
||||||
accessibilityHint={_(
|
|
||||||
msg`Sets hosting provider for password reset`,
|
|
||||||
)}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="globe"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<Text style={[pal.text, styles.textInput]} numberOfLines={1}>
|
|
||||||
{toNiceDomain(serviceUrl)}
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="pen"
|
|
||||||
size={12}
|
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="envelope"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
testID="forgotPasswordEmail"
|
|
||||||
style={[pal.text, styles.textInput]}
|
|
||||||
placeholder={_(msg`Email address`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoFocus
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
editable={!isProcessing}
|
|
||||||
accessibilityLabel={_(msg`Email`)}
|
|
||||||
accessibilityHint={_(msg`Sets email for password reset`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{error ? (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<View style={styles.errorIcon}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
|
||||||
<View style={s.flex1}>
|
|
||||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
|
||||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
{!serviceDescription || isProcessing ? (
|
|
||||||
<ActivityIndicator />
|
|
||||||
) : !email ? (
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="newPasswordButton"
|
|
||||||
onPress={onPressNext}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Go to next`)}
|
|
||||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{!serviceDescription || isProcessing ? (
|
|
||||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
|
||||||
<Trans>Processing...</Trans>
|
|
||||||
</Text>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
s.flexRow,
|
|
||||||
s.alignCenter,
|
|
||||||
s.mt20,
|
|
||||||
s.mb20,
|
|
||||||
pal.border,
|
|
||||||
s.borderBottom1,
|
|
||||||
{alignSelf: 'center', width: '90%'},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<View style={[s.flexRow, s.justifyCenter]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="skipSendEmailButton"
|
|
||||||
onPress={onEmailSent}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Go to next`)}
|
|
||||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
|
||||||
<Text type="xl" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Already have a code?</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<string>('')
|
|
||||||
const [serviceUrl, setServiceUrl] = useState<string>(
|
|
||||||
requestedAccount?.service || DEFAULT_SERVICE,
|
|
||||||
)
|
|
||||||
const [initialHandle, setInitialHandle] = useState<string>(
|
|
||||||
requestedAccount?.handle || '',
|
|
||||||
)
|
|
||||||
const [currentForm, setCurrentForm] = useState<Forms>(
|
|
||||||
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 (
|
|
||||||
<KeyboardAvoidingView testID="signIn" behavior="padding" style={pal.view}>
|
|
||||||
{currentForm === Forms.Login ? (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Sign in`)}
|
|
||||||
description={_(msg`Enter your username and password`)}>
|
|
||||||
<LoginForm
|
|
||||||
error={error}
|
|
||||||
serviceUrl={serviceUrl}
|
|
||||||
serviceDescription={serviceDescription}
|
|
||||||
initialHandle={initialHandle}
|
|
||||||
setError={setError}
|
|
||||||
setServiceUrl={setServiceUrl}
|
|
||||||
onPressBack={onPressBack}
|
|
||||||
onPressForgotPassword={onPressForgotPassword}
|
|
||||||
onPressRetryConnect={onPressRetryConnect}
|
|
||||||
/>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
) : undefined}
|
|
||||||
{currentForm === Forms.ChooseAccount ? (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Sign in as...`)}
|
|
||||||
description={_(msg`Select from an existing account`)}>
|
|
||||||
<ChooseAccountForm
|
|
||||||
onSelectAccount={onSelectAccount}
|
|
||||||
onPressBack={onPressBack}
|
|
||||||
/>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
) : undefined}
|
|
||||||
{currentForm === Forms.ForgotPassword ? (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Forgot Password`)}
|
|
||||||
description={_(msg`Let's get your password reset!`)}>
|
|
||||||
<ForgotPasswordForm
|
|
||||||
error={error}
|
|
||||||
serviceUrl={serviceUrl}
|
|
||||||
serviceDescription={serviceDescription}
|
|
||||||
setError={setError}
|
|
||||||
setServiceUrl={setServiceUrl}
|
|
||||||
onPressBack={gotoForm(Forms.Login)}
|
|
||||||
onEmailSent={gotoForm(Forms.SetNewPassword)}
|
|
||||||
/>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
) : undefined}
|
|
||||||
{currentForm === Forms.SetNewPassword ? (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Forgot Password`)}
|
|
||||||
description={_(msg`Let's get your password reset!`)}>
|
|
||||||
<SetNewPasswordForm
|
|
||||||
error={error}
|
|
||||||
serviceUrl={serviceUrl}
|
|
||||||
setError={setError}
|
|
||||||
onPressBack={gotoForm(Forms.ForgotPassword)}
|
|
||||||
onPasswordSet={gotoForm(Forms.PasswordUpdated)}
|
|
||||||
/>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
) : undefined}
|
|
||||||
{currentForm === Forms.PasswordUpdated ? (
|
|
||||||
<LoggedOutLayout
|
|
||||||
leadin=""
|
|
||||||
title={_(msg`Password updated`)}
|
|
||||||
description={_(msg`You can now sign in with your new password.`)}>
|
|
||||||
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
|
|
||||||
</LoggedOutLayout>
|
|
||||||
) : undefined}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<boolean>(false)
|
|
||||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
|
||||||
const [password, setPassword] = useState<string>('')
|
|
||||||
const passwordInputRef = useRef<TextInput>(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 (
|
|
||||||
<View testID="loginForm">
|
|
||||||
<ServerInputDialog
|
|
||||||
control={serverInputControl}
|
|
||||||
onSelect={setServiceUrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
|
||||||
<Trans>Sign into</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.borderDark, styles.group]}>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="globe"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="loginSelectServiceButton"
|
|
||||||
style={styles.textBtn}
|
|
||||||
onPress={onPressSelectService}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Select service`)}
|
|
||||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
|
||||||
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
|
||||||
{toNiceDomain(serviceUrl)}
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="pen"
|
|
||||||
size={12}
|
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
|
||||||
<Trans>Account</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.borderDark, styles.group]}>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="at"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
testID="loginUsernameInput"
|
|
||||||
style={[pal.text, styles.textInput]}
|
|
||||||
placeholder={_(msg`Username or email address`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoFocus
|
|
||||||
autoCorrect={false}
|
|
||||||
autoComplete="username"
|
|
||||||
returnKeyType="next"
|
|
||||||
textContentType="username"
|
|
||||||
onSubmitEditing={() => {
|
|
||||||
passwordInputRef.current?.focus()
|
|
||||||
}}
|
|
||||||
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
value={identifier}
|
|
||||||
onChangeText={str =>
|
|
||||||
setIdentifier((str || '').toLowerCase().trim())
|
|
||||||
}
|
|
||||||
editable={!isProcessing}
|
|
||||||
accessibilityLabel={_(msg`Username or email address`)}
|
|
||||||
accessibilityHint={_(
|
|
||||||
msg`Input the username or email address you used at signup`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="lock"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
testID="loginPasswordInput"
|
|
||||||
ref={passwordInputRef}
|
|
||||||
style={[pal.text, styles.textInput]}
|
|
||||||
placeholder={_(msg`Password`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
autoComplete="password"
|
|
||||||
returnKeyType="done"
|
|
||||||
enablesReturnKeyAutomatically={true}
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
secureTextEntry={true}
|
|
||||||
textContentType="password"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
onSubmitEditing={onPressNext}
|
|
||||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
|
||||||
editable={!isProcessing}
|
|
||||||
accessibilityLabel={_(msg`Password`)}
|
|
||||||
accessibilityHint={
|
|
||||||
identifier === ''
|
|
||||||
? _(msg`Input your password`)
|
|
||||||
: _(msg`Input the password tied to ${identifier}`)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="forgotPasswordButton"
|
|
||||||
style={styles.textInputInnerBtn}
|
|
||||||
onPress={onPressForgotPassword}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Forgot password`)}
|
|
||||||
accessibilityHint={_(msg`Opens password reset form`)}>
|
|
||||||
<Text style={pal.link}>
|
|
||||||
<Trans>Forgot</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{error ? (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<View style={styles.errorIcon}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
|
||||||
<View style={s.flex1}>
|
|
||||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
|
||||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
{!serviceDescription && error ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="loginRetryButton"
|
|
||||||
onPress={onPressRetryConnect}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Retry`)}
|
|
||||||
accessibilityHint={_(msg`Retries login`)}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Retry</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : !serviceDescription ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator />
|
|
||||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
|
||||||
<Trans>Connecting...</Trans>
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : isProcessing ? (
|
|
||||||
<ActivityIndicator />
|
|
||||||
) : isReady ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="loginNextButton"
|
|
||||||
onPress={onPressNext}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Go to next`)}
|
|
||||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<>
|
|
||||||
<View>
|
|
||||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
|
||||||
<Trans>Password updated!</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text type="lg" style={[pal.text, styles.instructions]}>
|
|
||||||
<Trans>You can now sign in with your new password.</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPressNext}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Close alert`)}
|
|
||||||
accessibilityHint={_(msg`Closes password update alert`)}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Okay</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<boolean>(false)
|
|
||||||
const [resetCode, setResetCode] = useState<string>('')
|
|
||||||
const [password, setPassword] = useState<string>('')
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<View>
|
|
||||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
|
||||||
<Trans>Set new password</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text type="lg" style={[pal.text, styles.instructions]}>
|
|
||||||
<Trans>
|
|
||||||
You will receive an email with a "reset code." Enter that code here,
|
|
||||||
then enter your new password.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
testID="newPasswordView"
|
|
||||||
style={[pal.view, pal.borderDark, styles.group]}>
|
|
||||||
<View
|
|
||||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="ticket"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
testID="resetCodeInput"
|
|
||||||
style={[pal.text, styles.textInput]}
|
|
||||||
placeholder={_(msg`Reset code`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
autoComplete="off"
|
|
||||||
value={resetCode}
|
|
||||||
onChangeText={setResetCode}
|
|
||||||
onFocus={() => setError('')}
|
|
||||||
onBlur={onBlur}
|
|
||||||
editable={!isProcessing}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={_(msg`Reset code`)}
|
|
||||||
accessibilityHint={_(
|
|
||||||
msg`Input code sent to your email for password reset`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={[pal.borderDark, styles.groupContent]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="lock"
|
|
||||||
style={[pal.textLight, styles.groupContentIcon]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
testID="newPasswordInput"
|
|
||||||
style={[pal.text, styles.textInput]}
|
|
||||||
placeholder={_(msg`New password`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
autoComplete="new-password"
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
secureTextEntry
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
editable={!isProcessing}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={_(msg`Password`)}
|
|
||||||
accessibilityHint={_(msg`Input new password`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{error ? (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<View style={styles.errorIcon}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
|
||||||
<View style={s.flex1}>
|
|
||||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
|
||||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
{isProcessing ? (
|
|
||||||
<ActivityIndicator />
|
|
||||||
) : !resetCode || !password ? (
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="setNewPasswordButton"
|
|
||||||
// Check the code before running the callback
|
|
||||||
onPress={onPressNext}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Go to next`)}
|
|
||||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{isProcessing ? (
|
|
||||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
|
||||||
<Trans>Updating...</Trans>
|
|
||||||
</Text>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
Loading…
Reference in New Issue