Merge branch 'main' into patch-3

This commit is contained in:
Minseo Lee 2024-02-19 14:17:59 +09:00 committed by GitHub
commit 6d422bb583
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2289 additions and 2383 deletions

View file

@ -0,0 +1,86 @@
import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state'
const ALLOWED_HOSTS = [
'bsky.social',
'bsky.app',
'staging.bsky.app',
'staging.bsky.dev',
'js.hcaptcha.com',
'newassets.hcaptcha.com',
'api2.hcaptcha.com',
]
export function CaptchaWebView({
url,
stateParam,
uiState,
onSuccess,
onError,
}: {
url: string
stateParam: string
uiState?: CreateAccountState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [uiState?.serviceUrl])
const wasSuccessful = React.useRef(false)
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
},
[],
)
const onNavigationStateChange = React.useCallback(
(e: WebViewNavigation) => {
if (wasSuccessful.current) return
const urlp = new URL(e.url)
if (urlp.host !== redirectHost) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
wasSuccessful.current = true
onSuccess(code)
},
[redirectHost, stateParam, onSuccess, onError],
)
return (
<WebView
source={{uri: url}}
javaScriptEnabled
style={styles.webview}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
/>
)
}
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: 'transparent',
borderRadius: 10,
},
})

View file

@ -0,0 +1,61 @@
import React from 'react'
import {StyleSheet} from 'react-native'
// @ts-ignore web only, we will always redirect to the app on web (CORS)
const REDIRECT_HOST = new URL(window.location.href).host
export function CaptchaWebView({
url,
stateParam,
onSuccess,
onError,
}: {
url: string
stateParam: string
onSuccess: (code: string) => void
onError: () => void
}) {
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
'captcha-iframe',
) as HTMLIFrameElement
try {
// @ts-ignore web
const href = frame?.contentWindow?.location.href
if (!href) return
const urlp = new URL(href)
// This shouldn't happen with CORS protections, but for good measure
if (urlp.host !== REDIRECT_HOST) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
onSuccess(code)
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
return (
<iframe
src={url}
style={styles.iframe}
id="captcha-iframe"
onLoad={onLoad}
/>
)
}
const styles = StyleSheet.create({
iframe: {
flex: 1,
borderWidth: 0,
borderRadius: 10,
backgroundColor: 'transparent',
},
})

View file

@ -13,33 +13,25 @@ import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell'
import {useSessionApi} from '#/state/session'
import {useCreateAccount, submit} from './state'
import {useCreateAccount, useSubmitCreateAccount} from './state'
import {useServiceQuery} from '#/state/queries/service'
import {
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
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} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
const pal = usePalette('default')
const {_} = useLingui()
const [uiState, uiDispatch] = useCreateAccount()
const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
React.useEffect(() => {
screen('CreateAccount')
@ -84,33 +76,48 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
if (!uiState.canNext) {
return
}
if (uiState.step < 3) {
uiDispatch({type: 'next'})
} else {
if (uiState.step === 2) {
uiDispatch({type: 'set-processing', value: true})
try {
await submit({
onboardingDispatch,
createAccount,
uiState,
uiDispatch,
_,
const res = await getAgent().resolveHandle({
handle: createFullHandle(uiState.handle, uiState.userDomain),
})
setBirthDate({birthDate: uiState.birthDate})
if (IS_PROD(uiState.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
if (res.data.did) {
uiDispatch({
type: 'set-error',
value: _(msg`That handle is already taken.`),
})
return
}
} catch {
// dont need to handle here
} 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,
uiState.canNext,
uiState.step,
uiState.isCaptchaRequired,
uiState.handle,
uiState.userDomain,
uiDispatch,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
_,
submit,
])
// rendering

View file

@ -73,6 +73,10 @@ export function Step1({
/>
<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>
@ -259,9 +263,6 @@ export function Step1({
)}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View>
)
}
@ -269,7 +270,7 @@ export function Step1({
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
marginBottom: 10,
},
dateInputButton: {
borderWidth: 1,

View file

@ -1,35 +1,19 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {StyleSheet, 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 {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isAndroid, isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
import {COUNTRY_CODES} from '#/lib/country-codes'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {HITSLOP_10} from '#/lib/constants'
/** STEP 3: Your user handle
* @field User handle
*/
export function Step2({
uiState,
uiDispatch,
@ -39,258 +23,34 @@ export function Step2({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const onPressRequest = React.useCallback(() => {
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)
if (phoneNumber && phoneNumber.isValid()) {
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
),
})
}
}, [uiState, uiDispatch, _])
const onPressRetry = React.useCallback(() => {
uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiDispatch])
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.formatInternational()
: '',
[
uiState.hasRequestedVerificationCode,
uiState.verificationPhone,
uiState.phoneCountry,
],
)
return (
<View>
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
{!uiState.hasRequestedVerificationCode ? (
<>
<View style={s.pb10}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneCountry">
<Trans>Country</Trans>
</Text>
<View
style={[
{position: 'relative'},
isAndroid && {
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
]}>
<RNPickerSelect
placeholder={{}}
value={uiState.phoneCountry}
onValueChange={value =>
uiDispatch({type: 'set-phone-country', value})
}
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 21,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
inputIOS: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 1,
borderColor: pal.border.borderColor,
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
}}
accessibilityLabel={_(msg`Select your phone's country`)}
accessibilityHint=""
accessibilityLabelledBy="phoneCountry"
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneNumber">
<Trans>Phone number</Trans>
</Text>
<TextInput
testID="phoneInput"
icon="phone"
placeholder={_(msg`Enter your phone number`)}
value={uiState.verificationPhone}
editable
onChange={value =>
uiDispatch({type: 'set-verification-phone', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input phone number for SMS verification`,
)}
accessibilityLabelledBy="phoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="tel"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter a phone number that can receive SMS text messages.
</Trans>
</Text>
</View>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID="requestCodeBtn"
type="primary"
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
)}
</View>
</>
) : (
<>
<View style={s.pb20}>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter the verification code sent to{' '}
{phoneNumberFormatted}.
</Trans>
</Text>
</View>
</>
)}
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb10}>
<TextInput
testID="handleInput"
icon="at"
placeholder="e.g. alice"
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={value => uiDispatch({type: 'set-handle', value})}
// 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>
)
}
@ -298,10 +58,6 @@ export function Step2({
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'}),
marginBottom: 10,
},
})

View file

@ -1,19 +1,23 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {Text} from 'view/com/util/text/Text'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {
CreateAccountState,
CreateAccountDispatch,
useSubmitCreateAccount,
} from './state'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro'
import {isWeb} from 'platform/detection'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
/** STEP 3: Your user handle
* @field User handle
*/
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,
@ -21,33 +25,66 @@ export function Step3({
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
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`Your user handle`)} />
<View style={s.pb10}>
<TextInput
testID="handleInput"
icon="at"
placeholder={_(msg`e.g. alice`)}
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={value => uiDispatch({type: 'set-handle', value})}
// 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>
<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}
@ -58,5 +95,20 @@ export function Step3({
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',
},
})

View file

@ -11,7 +11,7 @@ export function StepHeader({
children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
const numSteps = 3
return (
<View style={styles.container}>
<View>

View file

@ -1,8 +1,7 @@
import {useReducer} from 'react'
import {useCallback, useReducer} from 'react'
import {
ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
@ -11,10 +10,14 @@ import {getAge} from 'lib/strings/time'
import {logger} from '#/logger'
import {createFullHandle} from '#/lib/strings/handles'
import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants'
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {useSessionApi} from '#/state/session'
import {DEFAULT_SERVICE, IS_PROD} 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
@ -29,10 +32,6 @@ export type CreateAccountAction =
| {type: 'set-invite-code'; value: string}
| {type: 'set-email'; value: string}
| {type: 'set-password'; value: string}
| {type: 'set-phone-country'; value: CountryCode}
| {type: 'set-verification-phone'; value: string}
| {type: 'set-verification-code'; value: string}
| {type: 'set-has-requested-verification-code'; value: boolean}
| {type: 'set-handle'; value: string}
| {type: 'set-birth-date'; value: Date}
| {type: 'next'}
@ -49,10 +48,6 @@ export interface CreateAccountState {
inviteCode: string
email: string
password: string
phoneCountry: CountryCode
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string
birthDate: Date
@ -60,13 +55,14 @@ export interface CreateAccountState {
canBack: boolean
canNext: boolean
isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean
isCaptchaRequired: boolean
}
export type CreateAccountDispatch = (action: CreateAccountAction) => void
export function useCreateAccount() {
const {_} = useLingui()
return useReducer(createReducer({_}), {
step: 1,
error: undefined,
@ -77,144 +73,126 @@ export function useCreateAccount() {
inviteCode: '',
email: '',
password: '',
phoneCountry: 'US',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '',
birthDate: DEFAULT_DATE,
canBack: false,
canNext: false,
isInviteCodeRequired: false,
isPhoneVerificationRequired: false,
isCaptchaRequired: false,
})
}
export async function requestVerificationCode({
uiState,
uiDispatch,
_,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
_: I18nContext['_']
}) {
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.number
if (!phoneNumber) {
return
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
try {
const agent = new BskyAgent({service: uiState.serviceUrl})
await agent.com.atproto.temp.requestPhoneVerification({
phoneNumber,
})
uiDispatch({type: 'set-has-requested-verification-code', value: true})
} catch (e: any) {
logger.error(
`Failed to request sms verification code (${e.status} status)`,
{message: e},
)
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
}
uiDispatch({type: 'set-processing', value: false})
}
export function useSubmitCreateAccount(
uiState: CreateAccountState,
uiDispatch: CreateAccountDispatch,
) {
const {_} = useLingui()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const onboardingDispatch = useOnboardingDispatch()
export async function submit({
createAccount,
onboardingDispatch,
uiState,
uiDispatch,
_,
}: {
createAccount: SessionApiContext['createAccount']
onboardingDispatch: OnboardingDispatchContext
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
_: I18nContext['_']
}) {
if (!uiState.email) {
uiDispatch({type: 'set-step', value: 1})
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.isPhoneVerificationRequired &&
(!uiState.verificationPhone || !uiState.verificationCode)
) {
uiDispatch({type: 'set-step', value: 2})
return uiDispatch({
type: 'set-error',
value: _(msg`Please enter the code you received by SMS.`),
})
}
if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3})
return uiDispatch({
type: 'set-error',
value: _(msg`Please choose your handle.`),
})
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
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(),
verificationPhone: uiState.verificationPhone.trim(),
verificationCode: uiState.verificationCode.trim(),
})
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
errMsg = _(
msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
uiDispatch({type: 'set-step', value: 1})
} else if (e.error === 'InvalidPhoneVerification') {
uiDispatch({type: 'set-step', value: 2})
}
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_PROD(uiState.serviceUrl)) {
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,
})
}
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,
})
}
uiDispatch({type: 'set-processing', value: false})
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
throw 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) {
@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': {
return compute({...state, password: action.value})
}
case 'set-phone-country': {
return compute({...state, phoneCountry: action.value})
}
case 'set-verification-phone': {
return compute({
...state,
verificationPhone: action.value,
hasRequestedVerificationCode: false,
})
}
case 'set-verification-code': {
return compute({...state, verificationCode: action.value.trim()})
}
case 'set-has-requested-verification-code': {
return compute({...state, hasRequestedVerificationCode: action.value})
}
case 'set-handle': {
return compute({...state, handle: action.value})
}
@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
})
}
}
let increment = 1
if (state.step === 1 && !state.isPhoneVerificationRequired) {
increment = 2
}
return compute({...state, error: '', step: state.step + increment})
return compute({...state, error: '', step: state.step + 1})
}
case 'back': {
let decrement = 1
if (state.step === 3 && !state.isPhoneVerificationRequired) {
decrement = 2
}
return compute({...state, error: '', step: state.step - decrement})
return compute({...state, error: '', step: state.step - 1})
}
}
}
@ -328,23 +282,16 @@ function compute(state: CreateAccountState): CreateAccountState {
!!state.email &&
!!state.password
} else if (state.step === 2) {
canNext =
!state.isPhoneVerificationRequired ||
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) {
canNext = !!state.handle
} 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,
isPhoneVerificationRequired:
!!state.serviceDescription?.phoneVerificationRequired,
isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
}
}
function isValidVerificationCode(str: string): boolean {
return /[0-9]{6}/.test(str)
}

View file

@ -46,7 +46,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
}) {
const {isSandbox, hasSession} = useSession()
const {hasSession} = useSession()
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation()
@ -119,7 +119,7 @@ export function FeedPage({
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
Bluesky{' '}
{hasNew && (
<View
style={{
@ -154,16 +154,7 @@ export function FeedPage({
)
}
return <></>
}, [
isDesktop,
pal.view,
pal.text,
pal.textLight,
hasNew,
_,
isSandbox,
hasSession,
])
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
return (
<View testID={testID} style={s.h100pct}>

View file

@ -37,7 +37,6 @@ export function FeedsTabBar(
function FeedsTabBarPublic() {
const pal = usePalette('default')
const {isSandbox} = useSession()
return (
<CenteredView sideBorders>
@ -56,23 +55,7 @@ function FeedsTabBarPublic() {
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
{/*hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)*/}
</>
}
// onPress={emitSoftReset}
text="Bluesky "
/>
</View>
</CenteredView>

View file

@ -43,10 +43,13 @@ import {
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {isAndroid, isNative} from '#/platform/detection'
import {logger} from '#/logger'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
// FlatList maintainVisibleContentPosition breaks if too many items
// are prepended. This seems to be an optimal number based on *shrug*.
const PARENTS_CHUNK_SIZE = 15
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
// We don't insert any elements before the root row while loading.
// So the row we want to use as the scroll anchor is the first row.
@ -165,8 +168,10 @@ function PostThreadLoaded({
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(null)
const [maxVisible, setMaxVisible] = React.useState(100)
const [isPTRing, setIsPTRing] = React.useState(false)
const [maxParents, setMaxParents] = React.useState(
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
)
const [maxReplies, setMaxReplies] = React.useState(100)
const treeView = React.useMemo(
() => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
[threadViewPrefs, thread],
@ -206,10 +211,18 @@ function PostThreadLoaded({
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
} else {
// Everything is loaded.
arr.push(TOP_COMPONENT)
for (const parent of parents) {
arr.push(parent)
// Everything is loaded
let startIndex = Math.max(0, parents.length - maxParents)
if (startIndex === 0) {
arr.push(TOP_COMPONENT)
} else {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
}
for (let i = startIndex; i < parents.length; i++) {
arr.push(parents[i])
}
}
}
@ -220,17 +233,18 @@ function PostThreadLoaded({
if (highlightedPost.ctx.isChildLoading) {
arr.push(CHILD_SPINNER)
} else {
for (const reply of replies) {
arr.push(reply)
for (let i = 0; i < replies.length; i++) {
arr.push(replies[i])
if (i === maxReplies) {
arr.push(LOAD_MORE)
break
}
}
arr.push(BOTTOM_COMPONENT)
}
}
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
return arr
}, [skeleton, maxVisible, deferParents])
}, [skeleton, deferParents, maxParents, maxReplies])
// This is only used on the web to keep the post in view when its parents load.
// On native, we rely on `maintainVisibleContentPosition` instead.
@ -258,15 +272,28 @@ function PostThreadLoaded({
}
}, [thread])
const onPTR = React.useCallback(async () => {
setIsPTRing(true)
try {
await onRefresh()
} catch (err) {
logger.error('Failed to refresh posts thread', {message: err})
// On native, we reveal parents in chunks. Although they're all already
// loaded and FlatList already has its own virtualization, unfortunately FlatList
// has a bug that causes the content to jump around if too many items are getting
// prepended at once. It also jumps around if items get prepended during scroll.
// To work around this, we prepend rows after scroll bumps against the top and rests.
const needsBumpMaxParents = React.useRef(false)
const onStartReached = React.useCallback(() => {
if (maxParents < skeleton.parents.length) {
needsBumpMaxParents.current = true
}
setIsPTRing(false)
}, [setIsPTRing, onRefresh])
}, [maxParents, skeleton.parents.length])
const bumpMaxParentsIfNeeded = React.useCallback(() => {
if (!isNative) {
return
}
if (needsBumpMaxParents.current) {
needsBumpMaxParents.current = false
setMaxParents(n => n + PARENTS_CHUNK_SIZE)
}
}, [])
const onMomentumScrollEnd = bumpMaxParentsIfNeeded
const onScrollToTop = bumpMaxParentsIfNeeded
const renderItem = React.useCallback(
({item, index}: {item: RowItem; index: number}) => {
@ -301,7 +328,7 @@ function PostThreadLoaded({
} else if (item === LOAD_MORE) {
return (
<Pressable
onPress={() => setMaxVisible(n => n + 50)}
onPress={() => setMaxReplies(n => n + 50)}
style={[pal.border, pal.view, styles.itemContainer]}
accessibilityLabel={_(msg`Load more posts`)}
accessibilityHint="">
@ -345,6 +372,8 @@ function PostThreadLoaded({
const next = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
const hasUnrevealedParents =
index === 0 && maxParents < skeleton.parents.length
return (
<View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
@ -360,7 +389,9 @@ function PostThreadLoaded({
hasMore={item.ctx.hasMore}
showChildReplyLine={item.ctx.showChildReplyLine}
showParentReplyLine={item.ctx.showParentReplyLine}
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
hasPrecedingItem={
!!prev?.ctx.showChildReplyLine || hasUnrevealedParents
}
onPostReply={onRefresh}
/>
</View>
@ -383,6 +414,8 @@ function PostThreadLoaded({
onRefresh,
deferParents,
treeView,
skeleton.parents.length,
maxParents,
_,
],
)
@ -393,9 +426,10 @@ function PostThreadLoaded({
data={posts}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={isPTRing}
onRefresh={onPTR}
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
onStartReached={onStartReached}
onMomentumScrollEnd={onMomentumScrollEnd}
onScrollToTop={onScrollToTop}
maintainVisibleContentPosition={
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
}

View file

@ -27,7 +27,6 @@ import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'
import {PostSandboxWarning} from '../util/PostSandboxWarning'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format'
@ -44,6 +43,7 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {ThreadPost} from '#/state/queries/post-thread'
import {useSession} from 'state/session'
import {WhoCanReply} from '../threadgate/WhoCanReply'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
export function PostThreadItem({
post,
@ -164,8 +164,6 @@ let PostThreadItemLoaded = ({
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const {currentAccount} = useSession()
const hasEngagement = post.likeCount || post.repostCount
const rootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
@ -248,7 +246,6 @@ let PostThreadItemLoaded = ({
testID={`postThreadItem-by-${post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
accessible={false}>
<PostSandboxWarning />
<View style={[styles.layout]}>
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar
@ -357,9 +354,16 @@ let PostThreadItemLoaded = ({
translatorUrl={translatorUrl}
needsTranslation={needsTranslation}
/>
{hasEngagement ? (
{post.repostCount !== 0 || post.likeCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View style={[styles.expandedInfo, pal.border]}>
{post.repostCount ? (
{post.repostCount == null && post.likeCount == null && (
// If we're still loading and not sure, assume this post has engagement.
// This lets us avoid a layout shift for the common case (embedded post with likes/reposts).
// TODO: embeds should include metrics to avoid us having to guess.
<LoadingPlaceholder width={50} height={20} />
)}
{post.repostCount != null && post.repostCount !== 0 ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
@ -374,10 +378,8 @@ let PostThreadItemLoaded = ({
{pluralize(post.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{post.likeCount ? (
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link
style={styles.expandedInfoItem}
href={likesHref}
@ -392,13 +394,9 @@ let PostThreadItemLoaded = ({
{pluralize(post.likeCount, 'like')}
</Text>
</Link>
) : (
<></>
)}
) : null}
</View>
) : (
<></>
)}
) : null}
<View style={[s.pl10, s.pr10, s.pb5]}>
<PostCtrls
big
@ -438,8 +436,6 @@ let PostThreadItemLoaded = ({
? {marginRight: 4}
: {marginLeft: 2, marginRight: 2}
}>
<PostSandboxWarning />
<View
style={{
flexDirection: 'row',

View file

@ -21,7 +21,6 @@ import {PostEmbeds} from '../util/post-embeds'
import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'
import {RichText} from '../util/text/RichText'
import {PostSandboxWarning} from '../util/PostSandboxWarning'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
@ -160,8 +159,6 @@ let FeedItemInner = ({
href={href}
noFeedback
accessible={false}>
<PostSandboxWarning />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 52}}>
{isThreadChild && (

View file

@ -1,35 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useSession} from '#/state/session'
export function PostSandboxWarning() {
const {isSandbox} = useSession()
const pal = usePalette('default')
if (isSandbox) {
return (
<View style={styles.container}>
<Text
type="title-2xl"
style={[pal.text, styles.text]}
accessible={false}>
SANDBOX
</Text>
</View>
)
}
return null
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 6,
right: 10,
},
text: {
fontWeight: 'bold',
opacity: 0.07,
},
})

View file

@ -6,6 +6,7 @@ import {H1} from '#/components/Typography'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Loader} from '#/components/Loader'
export function Icons() {
const t = useTheme()
@ -36,6 +37,14 @@ export function Icons() {
<CalendarDays size="lg" fill={t.atoms.text.color} />
<CalendarDays size="xl" fill={t.atoms.text.color} />
</View>
<View style={[a.flex_row, a.gap_xl]}>
<Loader size="xs" fill={t.atoms.text.color} />
<Loader size="sm" fill={t.atoms.text.color} />
<Loader size="md" fill={t.atoms.text.color} />
<Loader size="lg" fill={t.atoms.text.color} />
<Loader size="xl" fill={t.atoms.text.color} />
</View>
</View>
)
}

View file

@ -19,9 +19,14 @@ export function Links() {
style={[a.text_md]}>
External
</InlineLink>
<InlineLink to="https://bsky.social" style={[a.text_md]}>
<InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text]}>
<H3>External with custom children</H3>
</InlineLink>
<InlineLink
to="https://bsky.social"
style={[a.text_md, t.atoms.text_contrast_low]}>
External with custom children
</InlineLink>
<InlineLink
to="https://bsky.social"
warnOnMismatchingTextChild

View file

@ -9,14 +9,13 @@ import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {s} from 'lib/styles'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useSession} from '#/state/session'
export function DesktopRightNav({routeName}: {routeName: string}) {
const pal = usePalette('default')
const palError = usePalette('error')
const {_} = useLingui()
const {isSandbox, hasSession, currentAccount} = useSession()
const {hasSession, currentAccount} = useSession()
const {isTablet} = useWebMediaQueries()
if (isTablet) {
@ -49,13 +48,6 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
paddingTop: hasSession ? 0 : 18,
},
]}>
{isSandbox ? (
<View style={[palError.view, styles.messageLine, s.p10]}>
<Text type="md" style={[palError.text, s.bold]}>
<Trans>SANDBOX. Posts and accounts are not permanent.</Trans>
</Text>
</View>
) : undefined}
<View style={[{flexWrap: 'wrap'}, s.flexRow]}>
{hasSession && (
<>