diff options
Diffstat (limited to 'src/screens/Signup')
-rw-r--r-- | src/screens/Signup/BackNextButtons.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.tsx | 88 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 89 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle.tsx | 217 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle/HandleSuggestions.tsx | 80 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle/index.tsx | 279 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo/Policies.tsx | 52 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo/index.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 21 | ||||
-rw-r--r-- | src/screens/Signup/state.ts | 16 |
10 files changed, 570 insertions, 276 deletions
diff --git a/src/screens/Signup/BackNextButtons.tsx b/src/screens/Signup/BackNextButtons.tsx index 888b9071e..5a85a85d1 100644 --- a/src/screens/Signup/BackNextButtons.tsx +++ b/src/screens/Signup/BackNextButtons.tsx @@ -9,7 +9,7 @@ import {Loader} from '#/components/Loader' export interface BackNextButtonsProps { hideNext?: boolean showRetry?: boolean - isLoading: boolean + isLoading?: boolean isNextDisabled?: boolean onBackPress: () => void onNextPress?: () => void diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index caa0aa28a..27951d4cc 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -1,20 +1,22 @@ -import React from 'react' -import {StyleSheet} from 'react-native' -import {WebView, WebViewNavigation} from 'react-native-webview' -import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' +import {useEffect, useMemo, useRef} from 'react' +import {WebView, type WebViewNavigation} from 'react-native-webview' +import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' -import {SignupState} from '#/screens/Signup/state' +import {type SignupState} from '#/screens/Signup/state' const ALLOWED_HOSTS = [ 'bsky.social', 'bsky.app', 'staging.bsky.app', 'staging.bsky.dev', + 'app.staging.bsky.dev', 'js.hcaptcha.com', 'newassets.hcaptcha.com', 'api2.hcaptcha.com', ] +const MIN_DELAY = 3_500 + export function CaptchaWebView({ url, stateParam, @@ -28,49 +30,67 @@ export function CaptchaWebView({ onSuccess: (code: string) => void onError: (error: unknown) => void }) { - const redirectHost = React.useMemo(() => { + const startedAt = useRef(Date.now()) + const successTo = useRef<NodeJS.Timeout>() + + useEffect(() => { + return () => { + if (successTo.current) { + clearTimeout(successTo.current) + } + } + }, []) + + const redirectHost = useMemo(() => { if (!state?.serviceUrl) return 'bsky.app' return state?.serviceUrl && new URL(state?.serviceUrl).host === 'staging.bsky.dev' - ? 'staging.bsky.app' + ? 'app.staging.bsky.dev' : 'bsky.app' }, [state?.serviceUrl]) - const wasSuccessful = React.useRef(false) + const wasSuccessful = useRef(false) - const onShouldStartLoadWithRequest = React.useCallback( - (event: ShouldStartLoadRequest) => { - const urlp = new URL(event.url) - return ALLOWED_HOSTS.includes(urlp.host) - }, - [], - ) + const onShouldStartLoadWithRequest = (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 onNavigationStateChange = (e: WebViewNavigation) => { + if (wasSuccessful.current) return - const urlp = new URL(e.url) - if (urlp.host !== redirectHost) return + const urlp = new URL(e.url) + if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return - const code = urlp.searchParams.get('code') - if (urlp.searchParams.get('state') !== stateParam || !code) { - onError({error: 'Invalid state or code'}) - return - } + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError({error: 'Invalid state or code'}) + return + } - wasSuccessful.current = true + // We want to delay the completion of this screen ever so slightly so that it doesn't appear to be a glitch if it completes too fast + wasSuccessful.current = true + const now = Date.now() + const timeTaken = now - startedAt.current + if (timeTaken < MIN_DELAY) { + successTo.current = setTimeout(() => { + onSuccess(code) + }, MIN_DELAY - timeTaken) + } else { onSuccess(code) - }, - [redirectHost, stateParam, onSuccess, onError], - ) + } + } return ( <WebView source={{uri: url}} javaScriptEnabled - style={styles.webview} + style={{ + flex: 1, + backgroundColor: 'transparent', + borderRadius: 10, + }} onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} onNavigationStateChange={onNavigationStateChange} scrollEnabled={false} @@ -83,11 +103,3 @@ export function CaptchaWebView({ /> ) } - -const styles = StyleSheet.create({ - webview: { - flex: 1, - backgroundColor: 'transparent', - borderRadius: 10, - }, -}) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index 388deecaf..8ea893c4a 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -1,21 +1,74 @@ -import React from 'react' -import {ActivityIndicator, View} from 'react-native' +import React, {useEffect, useState} from 'react' +import {ActivityIndicator, Platform, View} from 'react-native' +import ReactNativeDeviceAttest from 'react-native-device-attest' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {nanoid} from 'nanoid/non-secure' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {useSignupContext} from '#/screens/Signup/state' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' import {atoms as a, useTheme} from '#/alf' import {FormError} from '#/components/forms/FormError' +import {GCP_PROJECT_ID} from '#/env' import {BackNextButtons} from '../BackNextButtons' -const CAPTCHA_PATH = '/gate/signup' +const CAPTCHA_PATH = + isWeb || GCP_PROJECT_ID === 0 ? '/gate/signup' : '/gate/signup/attempt-attest' export function StepCaptcha() { + if (isWeb) { + return <StepCaptchaInner /> + } else { + return <StepCaptchaNative /> + } +} + +export function StepCaptchaNative() { + const [token, setToken] = useState<string>() + const [payload, setPayload] = useState<string>() + const [ready, setReady] = useState(false) + + useEffect(() => { + ;(async () => { + logger.debug('trying to generate attestation token...') + try { + if (isIOS) { + logger.debug('starting to generate devicecheck token...') + const token = await ReactNativeDeviceAttest.getDeviceCheckToken() + setToken(token) + logger.debug(`generated devicecheck token: ${token}`) + } else { + const {token, payload} = + await ReactNativeDeviceAttest.getIntegrityToken('signup') + setToken(token) + setPayload(base64UrlEncode(payload)) + } + } catch (e: any) { + logger.error(e) + } finally { + setReady(true) + } + })() + }, []) + + if (!ready) { + return <View /> + } + + return <StepCaptchaInner token={token} payload={payload} /> +} + +function StepCaptchaInner({ + token, + payload, +}: { + token?: string + payload?: string +}) { const {_} = useLingui() const theme = useTheme() const {state, dispatch} = useSignupContext() @@ -33,8 +86,24 @@ export function StepCaptcha() { newUrl.searchParams.set('state', stateParam) newUrl.searchParams.set('colorScheme', theme.name) + if (isNative && token) { + newUrl.searchParams.set('platform', Platform.OS) + newUrl.searchParams.set('token', token) + if (isAndroid && payload) { + newUrl.searchParams.set('payload', payload) + } + } + return newUrl.href - }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + }, [ + state.serviceUrl, + state.handle, + state.userDomain, + stateParam, + theme.name, + token, + payload, + ]) const onSuccess = React.useCallback( (code: string) => { @@ -75,7 +144,7 @@ export function StepCaptcha() { return ( <ScreenTransition> - <View style={[a.gap_lg]}> + <View style={[a.gap_lg, a.pt_lg]}> <View style={[ a.w_full, @@ -105,3 +174,13 @@ export function StepCaptcha() { </ScreenTransition> ) } + +function base64UrlEncode(data: string): string { + const encoder = new TextEncoder() + const bytes = encoder.encode(data) + + const binaryString = String.fromCharCode(...bytes) + const base64 = btoa(binaryString) + + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, '') +} diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx deleted file mode 100644 index 8bf0c3364..000000000 --- a/src/screens/Signup/StepHandle.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React, {useRef} from 'react' -import {View} from 'react-native' -import {msg, Plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import { - createFullHandle, - MAX_SERVICE_HANDLE_LENGTH, - validateServiceHandle, -} from '#/lib/strings/handles' -import {logger} from '#/logger' -import {useAgent} from '#/state/session' -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 {useThrottledValue} from '#/components/hooks/useThrottledValue' -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' -import {BackNextButtons} from './BackNextButtons' - -export function StepHandle() { - const {_} = useLingui() - const t = useTheme() - const {state, dispatch} = useSignupContext() - const agent = useAgent() - const handleValueRef = useRef<string>(state.handle) - const [draftValue, setDraftValue] = React.useState(state.handle) - const isLoading = useThrottledValue(state.isLoading, 500) - - const onNextPress = React.useCallback(async () => { - const handle = handleValueRef.current.trim() - dispatch({ - type: 'setHandle', - value: handle, - }) - - const newValidCheck = validateServiceHandle(handle, state.userDomain) - if (!newValidCheck.overall) { - return - } - - try { - dispatch({type: 'setIsLoading', value: true}) - - const res = await agent.resolveHandle({ - handle: createFullHandle(handle, state.userDomain), - }) - - if (res.data.did) { - dispatch({ - type: 'setError', - value: _(msg`That handle is already taken.`), - field: 'handle', - }) - logger.metric('signup:handleTaken', {}, {statsig: true}) - return - } - } catch (e) { - // Don't have to handle - } finally { - dispatch({type: 'setIsLoading', value: false}) - } - - logger.metric( - 'signup:nextPressed', - { - activeStep: state.activeStep, - phoneVerificationRequired: - state.serviceDescription?.phoneVerificationRequired, - }, - {statsig: true}, - ) - // phoneVerificationRequired is actually whether a captcha is required - if (!state.serviceDescription?.phoneVerificationRequired) { - dispatch({ - type: 'submit', - task: {verificationCode: undefined, mutableProcessed: false}, - }) - return - } - dispatch({type: 'next'}) - }, [ - _, - dispatch, - state.activeStep, - state.serviceDescription?.phoneVerificationRequired, - state.userDomain, - agent, - ]) - - const onBackPress = React.useCallback(() => { - const handle = handleValueRef.current.trim() - dispatch({ - type: 'setHandle', - value: handle, - }) - dispatch({type: 'prev'}) - logger.metric( - 'signup:backPressed', - {activeStep: state.activeStep}, - {statsig: true}, - ) - }, [dispatch, state.activeStep]) - - const validCheck = validateServiceHandle(draftValue, state.userDomain) - return ( - <ScreenTransition> - <View style={[a.gap_lg]}> - <View> - <TextField.Root> - <TextField.Icon icon={At} /> - <TextField.Input - testID="handleInput" - onChangeText={val => { - if (state.error) { - dispatch({type: 'setError', value: ''}) - } - - // These need to always be in sync. - handleValueRef.current = val - setDraftValue(val) - }} - label={_(msg`Type your desired username`)} - defaultValue={draftValue} - autoCapitalize="none" - autoCorrect={false} - autoFocus - autoComplete="off" - /> - </TextField.Root> - </View> - {draftValue !== '' && ( - <Text style={[a.text_md]}> - <Trans> - Your full username will be{' '} - <Text style={[a.text_md, a.font_bold]}> - @{createFullHandle(draftValue, state.userDomain)} - </Text> - </Trans> - </Text> - )} - - {draftValue !== '' && ( - <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 || - draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? ( - <Text style={[a.text_md, a.flex_1]}> - <Trans> - No longer than{' '} - <Plural - value={MAX_SERVICE_HANDLE_LENGTH} - other="# characters" - /> - </Trans> - </Text> - ) : ( - <Text style={[a.text_md, a.flex_1]}> - <Trans>At least 3 characters</Trans> - </Text> - )} - </View> - </View> - )} - </View> - <BackNextButtons - isLoading={isLoading} - isNextDisabled={!validCheck.overall} - onBackPress={onBackPress} - onNextPress={onNextPress} - /> - </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}} /> -} diff --git a/src/screens/Signup/StepHandle/HandleSuggestions.tsx b/src/screens/Signup/StepHandle/HandleSuggestions.tsx new file mode 100644 index 000000000..3d219d886 --- /dev/null +++ b/src/screens/Signup/StepHandle/HandleSuggestions.tsx @@ -0,0 +1,80 @@ +import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated' +import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, native, useTheme} from '#/alf' +import {borderRadius} from '#/alf/tokens' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function HandleSuggestions({ + suggestions, + onSelect, +}: { + suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[] + onSelect: ( + suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion, + ) => void +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Animated.View + entering={native(FadeInDown.easing(Easing.out(Easing.exp)))} + exiting={native(FadeOut)} + style={[ + a.flex_1, + a.border, + a.rounded_sm, + t.atoms.shadow_sm, + t.atoms.bg, + t.atoms.border_contrast_low, + a.mt_xs, + a.z_50, + a.w_full, + a.zoom_fade_in, + ]}> + {suggestions.map((suggestion, index) => ( + <Button + label={_( + msg({ + message: `Select ${suggestion.handle}`, + comment: `Accessibility label for a username suggestion in the account creation flow`, + }), + )} + key={index} + onPress={() => onSelect(suggestion)} + hoverStyle={[t.atoms.bg_contrast_25]} + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_md, + a.border_b, + t.atoms.border_contrast_low, + index === 0 && { + borderTopStartRadius: borderRadius.sm, + borderTopEndRadius: borderRadius.sm, + }, + index === suggestions.length - 1 && [ + { + borderBottomStartRadius: borderRadius.sm, + borderBottomEndRadius: borderRadius.sm, + }, + a.border_b_0, + ], + ]}> + <Text style={[a.text_md]}>{suggestion.handle}</Text> + <Text style={[a.text_sm, {color: t.palette.positive_700}]}> + <Trans comment="Shown next to an available username suggestion in the account creation flow"> + Available + </Trans> + </Text> + </Button> + ))} + </Animated.View> + ) +} diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx new file mode 100644 index 000000000..aaab435ae --- /dev/null +++ b/src/screens/Signup/StepHandle/index.tsx @@ -0,0 +1,279 @@ +import {useState} from 'react' +import {View} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + LayoutAnimationConfig, + LinearTransition, +} from 'react-native-reanimated' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGate} from '#/lib/statsig/statsig' +import { + createFullHandle, + MAX_SERVICE_HANDLE_LENGTH, + validateServiceHandle, +} from '#/lib/strings/handles' +import {logger} from '#/logger' +import { + checkHandleAvailability, + useHandleAvailabilityQuery, +} from '#/state/queries/handle-availability' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {useSignupContext} from '#/screens/Signup/state' +import {atoms as a, native, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {Text} from '#/components/Typography' +import {IS_INTERNAL} from '#/env' +import {BackNextButtons} from '../BackNextButtons' +import {HandleSuggestions} from './HandleSuggestions' + +export function StepHandle() { + const {_} = useLingui() + const t = useTheme() + const gate = useGate() + const {state, dispatch} = useSignupContext() + const [draftValue, setDraftValue] = useState(state.handle) + const isNextLoading = useThrottledValue(state.isLoading, 500) + + const validCheck = validateServiceHandle(draftValue, state.userDomain) + + const { + debouncedUsername: debouncedDraftValue, + enabled: queryEnabled, + query: {data: isHandleAvailable, isPending}, + } = useHandleAvailabilityQuery({ + username: draftValue, + serviceDid: state.serviceDescription?.did ?? 'UNKNOWN', + serviceDomain: state.userDomain, + birthDate: state.dateOfBirth.toISOString(), + email: state.email, + enabled: validCheck.overall, + }) + + const onNextPress = async () => { + const handle = draftValue.trim() + dispatch({ + type: 'setHandle', + value: handle, + }) + + if (!validCheck.overall) { + return + } + + dispatch({type: 'setIsLoading', value: true}) + + try { + const {available: handleAvailable} = await checkHandleAvailability( + createFullHandle(handle, state.userDomain), + state.serviceDescription?.did ?? 'UNKNOWN', + {typeahead: false}, + ) + + if (!handleAvailable) { + dispatch({ + type: 'setError', + value: _(msg`That username is already taken`), + field: 'handle', + }) + return + } + } catch (error) { + logger.error('Failed to check handle availability on next press', { + safeMessage: error, + }) + // do nothing on error, let them pass + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + + logger.metric( + 'signup:nextPressed', + { + activeStep: state.activeStep, + phoneVerificationRequired: + state.serviceDescription?.phoneVerificationRequired, + }, + {statsig: true}, + ) + // phoneVerificationRequired is actually whether a captcha is required + if (!state.serviceDescription?.phoneVerificationRequired) { + dispatch({ + type: 'submit', + task: {verificationCode: undefined, mutableProcessed: false}, + }) + return + } + dispatch({type: 'next'}) + } + + const onBackPress = () => { + const handle = draftValue.trim() + dispatch({ + type: 'setHandle', + value: handle, + }) + dispatch({type: 'prev'}) + logger.metric( + 'signup:backPressed', + {activeStep: state.activeStep}, + {statsig: true}, + ) + } + + const hasDebounceSettled = draftValue === debouncedDraftValue + const isHandleTaken = + !isPending && + queryEnabled && + isHandleAvailable && + !isHandleAvailable.available + const isNotReady = isPending || !hasDebounceSettled + const isNextDisabled = + !validCheck.overall || !!state.error || isNotReady ? true : isHandleTaken + + const textFieldInvalid = + isHandleTaken || + !validCheck.frontLengthNotTooLong || + !validCheck.handleChars || + !validCheck.hyphenStartOrEnd || + !validCheck.totalLength + + return ( + <ScreenTransition> + <View style={[a.gap_sm, a.pt_lg, a.z_10]}> + <View> + <TextField.Root isInvalid={textFieldInvalid}> + <TextField.Icon icon={AtIcon} /> + <TextField.Input + testID="handleInput" + onChangeText={val => { + if (state.error) { + dispatch({type: 'setError', value: ''}) + } + setDraftValue(val.toLocaleLowerCase()) + }} + label={state.userDomain} + value={draftValue} + keyboardType="ascii-capable" // fix for iOS replacing -- with — + autoCapitalize="none" + autoCorrect={false} + autoFocus + autoComplete="off" + /> + {draftValue.length > 0 && ( + <TextField.GhostText value={state.userDomain}> + {draftValue} + </TextField.GhostText> + )} + {isHandleAvailable?.available && ( + <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} /> + )} + </TextField.Root> + </View> + <LayoutAnimationConfig skipEntering skipExiting> + <View style={[a.gap_xs]}> + {state.error && ( + <Requirement> + <RequirementText>{state.error}</RequirementText> + </Requirement> + )} + {isHandleTaken && validCheck.overall && ( + <> + <Requirement> + <RequirementText> + <Trans> + {createFullHandle(draftValue, state.userDomain)} is not + available + </Trans> + </RequirementText> + </Requirement> + {isHandleAvailable.suggestions && + isHandleAvailable.suggestions.length > 0 && + (gate('handle_suggestions') || IS_INTERNAL) && ( + <HandleSuggestions + suggestions={isHandleAvailable.suggestions} + onSelect={suggestion => { + setDraftValue( + suggestion.handle.slice( + 0, + state.userDomain.length * -1, + ), + ) + logger.metric('signup:handleSuggestionSelected', { + method: suggestion.method, + }) + }} + /> + )} + </> + )} + {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && ( + <Requirement> + {!validCheck.hyphenStartOrEnd ? ( + <RequirementText> + <Trans>Username cannot begin or end with a hyphen</Trans> + </RequirementText> + ) : ( + <RequirementText> + <Trans> + Username must only contain letters (a-z), numbers, and + hyphens + </Trans> + </RequirementText> + )} + </Requirement> + )} + <Requirement> + {(!validCheck.frontLengthNotTooLong || + !validCheck.totalLength) && ( + <RequirementText> + <Trans> + Username cannot be longer than{' '} + <Plural + value={MAX_SERVICE_HANDLE_LENGTH} + other="# characters" + /> + </Trans> + </RequirementText> + )} + </Requirement> + </View> + </LayoutAnimationConfig> + </View> + <Animated.View layout={native(LinearTransition)}> + <BackNextButtons + isLoading={isNextLoading} + isNextDisabled={isNextDisabled} + onBackPress={onBackPress} + onNextPress={onNextPress} + /> + </Animated.View> + </ScreenTransition> + ) +} + +function Requirement({children}: {children: React.ReactNode}) { + return ( + <Animated.View + style={[a.w_full]} + layout={native(LinearTransition)} + entering={native(FadeIn)} + exiting={native(FadeOut)}> + {children} + </Animated.View> + ) +} + +function RequirementText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <Text style={[a.text_sm, a.flex_1, {color: t.palette.negative_500}]}> + {children} + </Text> + ) +} diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx index 17980172d..0bc14fa6a 100644 --- a/src/screens/Signup/StepInfo/Policies.tsx +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -4,11 +4,42 @@ import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {webLinks} from '#/lib/constants' +import {useGate} from '#/lib/statsig/statsig' import {atoms as a, useTheme} from '#/alf' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Admonition} from '#/components/Admonition' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +function CommunityGuidelinesNotice({}: {}) { + const {_} = useLingui() + const gate = useGate() + + if (gate('disable_onboarding_policy_update_notice')) return null + + return ( + <View style={[a.pt_xs]}> + <Admonition type="tip"> + <Trans> + You also agree to{' '} + <InlineLinkText + label={_(msg`Bluesky's Community Guidelines`)} + to={webLinks.communityDeprecated}> + Bluesky’s Community Guidelines + </InlineLinkText> + . An{' '} + <InlineLinkText + label={_(msg`Bluesky's Updated Community Guidelines`)} + to={webLinks.community}> + updated version of our Community Guidelines + </InlineLinkText>{' '} + will take effect on October 13th. + </Trans> + </Admonition> + </View> + ) +} + export const Policies = ({ serviceDescription, needsGuardian, @@ -30,14 +61,13 @@ export const Policies = ({ 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]}> + <View style={[a.gap_sm]}> + <Admonition type="info"> <Trans> This service has not provided terms of service or a privacy policy. </Trans> - </Text> + </Admonition> + <CommunityGuidelinesNotice /> </View> ) } @@ -102,19 +132,21 @@ export const Policies = ({ ) : null} {under13 ? ( - <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Admonition type="error"> <Trans> You must be 13 years of age or older to create an account. </Trans> - </Text> + </Admonition> ) : needsGuardian ? ( - <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Admonition type="warning"> <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> + </Admonition> ) : undefined} + + <CommunityGuidelinesNotice /> </View> ) } diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx index f24cd0e45..cf4a9297e 100644 --- a/src/screens/Signup/StepInfo/index.tsx +++ b/src/screens/Signup/StepInfo/index.tsx @@ -144,7 +144,7 @@ export function StepInfo({ return ( <ScreenTransition> - <View style={[a.gap_md]}> + <View style={[a.gap_md, a.pt_lg]}> <FormError error={state.error} /> <HostingProvider minimal diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 03f4e2cdd..807bbff4f 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,11 +1,14 @@ import {useEffect, useReducer, useState} from 'react' import {AppState, type AppStateStatus, View} from 'react-native' +import ReactNativeDeviceAttest from 'react-native-device-attest' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {FEEDBACK_FORM_URL} from '#/lib/constants' +import {logger} from '#/logger' +import {isAndroid} from '#/platform/detection' import {useServiceQuery} from '#/state/queries/service' import {useStarterPackQuery} from '#/state/queries/starter-packs' import {useActiveStarterPack} from '#/state/shell/starter-pack' @@ -26,6 +29,7 @@ import {Divider} from '#/components/Divider' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +import {GCP_PROJECT_ID} from '#/env' import * as bsky from '#/types/bsky' export function Signup({onPressBack}: {onPressBack: () => void}) { @@ -101,6 +105,16 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { return () => subscription.remove() }, []) + // On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen. + useEffect(() => { + if (!isAndroid) { + return + } + ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err => + logger.error(err), + ) + }, []) + return ( <SignupContext.Provider value={{state, dispatch}}> <LoggedOutLayout @@ -143,8 +157,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { a.pt_2xl, !gtMobile && {paddingBottom: 100}, ]}> - <View style={[a.gap_sm, a.pb_3xl]}> - <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> + <View style={[a.gap_sm, a.pb_sm]}> + <Text + style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}> <Trans> Step {state.activeStep + 1} of{' '} {state.serviceDescription && @@ -153,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { : '3'} </Trans> </Text> - <Text style={[a.text_3xl, a.font_bold]}> + <Text style={[a.text_3xl, a.font_heavy]}> {state.activeStep === SignupStep.INFO ? ( <Trans>Your account</Trans> ) : state.activeStep === SignupStep.HANDLE ? ( diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 48ea4ccd9..ae0b20f1c 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -15,6 +15,7 @@ import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' +import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate' export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -252,6 +253,8 @@ export function useSubmitSignup() { const {_} = useLingui() const {createAccount} = useSessionApi() const onboardingDispatch = useOnboardingDispatch() + const preemptivelyCompleteActivePolicyUpdate = + usePreemptivelyCompleteActivePolicyUpdate() return useCallback( async (state: SignupState, dispatch: (action: SignupAction) => void) => { @@ -325,6 +328,12 @@ export function useSubmitSignup() { }, ) + /** + * Marks any active policy update as completed, since user just agreed + * to TOS/privacy during sign up + */ + preemptivelyCompleteActivePolicyUpdate() + /* * Must happen last so that if the user has multiple tabs open and * createAccount fails, one tab is not stuck in onboarding — Eric @@ -363,6 +372,11 @@ export function useSubmitSignup() { dispatch({type: 'setIsLoading', value: false}) } }, - [_, onboardingDispatch, createAccount], + [ + _, + onboardingDispatch, + createAccount, + preemptivelyCompleteActivePolicyUpdate, + ], ) } |