diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-08-08 01:01:01 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-07 17:01:01 -0500 |
commit | 93c4719a2140070b33f69dd0f12b4de2619a25a6 (patch) | |
tree | 0396daa890b1023097385748f2194b16d1fa6fa4 | |
parent | f708e884107c75559759b22901c87e57d5b979da (diff) | |
download | voidsky-93c4719a2140070b33f69dd0f12b4de2619a25a6.tar.zst |
Check handle as you type (#8601)
* check handle as you type * metrics * add metric types * fix overflow * only check reserved handles for bsky.social, fix test * change validation check name * tweak input * move ghosttext component to textfield * tweak styles to try and match latest * add suggestions * improvements, metrics * share logic between typeahead and next button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * update checks, disable button if unavailable * convert to lowercase * fix bug with checkHandleAvailability * add gate * move files around to make clearer * fix bad import * Fix flashing next button * Enable for TF --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com> Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r-- | __tests__/lib/strings/handles.test.ts | 10 | ||||
-rw-r--r-- | assets/icons/circle_stroke2_corner0_rounded.svg | 1 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 84 | ||||
-rw-r--r-- | src/components/icons/Circle.tsx | 5 | ||||
-rw-r--r-- | src/lib/constants.ts | 3 | ||||
-rw-r--r-- | src/lib/statsig/gates.ts | 1 | ||||
-rw-r--r-- | src/lib/strings/handles.ts | 6 | ||||
-rw-r--r-- | src/logger/metrics.ts | 4 | ||||
-rw-r--r-- | src/screens/Signup/BackNextButtons.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 2 | ||||
-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/index.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 7 | ||||
-rw-r--r-- | src/state/queries/handle-availability.ts | 126 | ||||
-rw-r--r-- | yarn.lock | 8 |
18 files changed, 593 insertions, 246 deletions
diff --git a/__tests__/lib/strings/handles.test.ts b/__tests__/lib/strings/handles.test.ts index 4456fae94..f3b289afd 100644 --- a/__tests__/lib/strings/handles.test.ts +++ b/__tests__/lib/strings/handles.test.ts @@ -1,4 +1,4 @@ -import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' +import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' describe('handle validation', () => { const valid = [ @@ -18,17 +18,17 @@ describe('handle validation', () => { }) const invalid = [ - ['al', 'bsky.social', 'frontLength'], + ['al', 'bsky.social', 'frontLengthNotTooShort'], ['-alice', 'bsky.social', 'hyphenStartOrEnd'], ['alice-', 'bsky.social', 'hyphenStartOrEnd'], ['%%%', 'bsky.social', 'handleChars'], - ['1234567890123456789', 'bsky.social', 'frontLength'], + ['1234567890123456789', 'bsky.social', 'frontLengthNotTooLong'], [ '1234567890123456789', 'my-custom-pds-with-long-name.social', - 'frontLength', + 'frontLengthNotTooLong', ], - ['al', 'my-custom-pds-with-long-name.social', 'frontLength'], + ['al', 'my-custom-pds-with-long-name.social', 'frontLengthNotTooShort'], ['a'.repeat(300), 'toolong.com', 'totalLength'], ] satisfies [string, string, keyof IsValidHandle][] it.each(invalid)( diff --git a/assets/icons/circle_stroke2_corner0_rounded.svg b/assets/icons/circle_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..98fe755b0 --- /dev/null +++ b/assets/icons/circle_stroke2_corner0_rounded.svg @@ -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="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/></svg> diff --git a/package.json b/package.json index dc8888860..2b44ead9f 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.26", + "@atproto/api": "^0.16.2", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 9b7ada319..3913c3283 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {createContext, useContext, useMemo, useRef} from 'react' import { type AccessibilityProps, StyleSheet, @@ -16,7 +16,9 @@ import { applyFonts, atoms as a, ios, + platform, type TextStyleProp, + tokens, useAlf, useTheme, web, @@ -25,7 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' -const Context = React.createContext<{ +const Context = createContext<{ inputRef: React.RefObject<TextInput> | null isInvalid: boolean hovered: boolean @@ -48,7 +50,7 @@ const Context = React.createContext<{ export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}> export function Root({children, isInvalid = false}: RootProps) { - const inputRef = React.useRef<TextInput>(null) + const inputRef = useRef<TextInput>(null) const { state: hovered, onIn: onHoverIn, @@ -56,7 +58,7 @@ export function Root({children, isInvalid = false}: RootProps) { } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const context = React.useMemo( + const context = useMemo( () => ({ inputRef, hovered, @@ -96,7 +98,7 @@ export function Root({children, isInvalid = false}: RootProps) { export function useSharedInputStyles() { const t = useTheme() - return React.useMemo(() => { + return useMemo(() => { const hover: ViewStyle[] = [ { borderColor: t.palette.contrast_100, @@ -158,7 +160,7 @@ export function createInput(Component: typeof TextInput) { }: InputProps) { const t = useTheme() const {fonts} = useAlf() - const ctx = React.useContext(Context) + const ctx = useContext(Context) const withinRoot = Boolean(ctx.inputRef) const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = @@ -283,8 +285,8 @@ export function LabelText({ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) { const t = useTheme() - const ctx = React.useContext(Context) - const {hover, focus, errorHover, errorFocus} = React.useMemo(() => { + const ctx = useContext(Context) + const {hover, focus, errorHover, errorFocus} = useMemo(() => { const hover: TextStyle[] = [ { color: t.palette.contrast_800, @@ -342,7 +344,7 @@ export function SuffixText({ } >) { const t = useTheme() - const ctx = React.useContext(Context) + const ctx = useContext(Context) return ( <Text accessibilityLabel={label} @@ -362,3 +364,67 @@ export function SuffixText({ </Text> ) } + +export function GhostText({ + children, + value, +}: { + children: string + value: string +}) { + const t = useTheme() + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text + return ( + <View + style={[ + a.pointer_events_none, + a.absolute, + a.z_10, + { + paddingLeft: platform({ + native: + // input padding + tokens.space.md + + // icon + tokens.space.xl + + // icon padding + tokens.space.xs + + // text input padding + tokens.space.xs, + web: + // icon + tokens.space.xl + + // icon padding + tokens.space.xs + + // text input padding + tokens.space.xs, + }), + }, + web(a.pr_md), + a.overflow_hidden, + a.max_w_full, + ]} + aria-hidden={true} + accessibilityElementsHidden + importantForAccessibility="no-hide-descendants"> + <Text + style={[ + {color: 'transparent'}, + a.text_md, + {lineHeight: a.text_md.fontSize * 1.1875}, + a.w_full, + ]} + numberOfLines={1}> + {children} + <Text + style={[ + t.atoms.text_contrast_low, + a.text_md, + {lineHeight: a.text_md.fontSize * 1.1875}, + ]}> + {value} + </Text> + </Text> + </View> + ) +} diff --git a/src/components/icons/Circle.tsx b/src/components/icons/Circle.tsx new file mode 100644 index 000000000..93d837119 --- /dev/null +++ b/src/components/icons/Circle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Circle_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z', +}) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ab52b8710..21f0ab870 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,6 +5,7 @@ export const LOCAL_DEV_SERVICE = Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://staging.bsky.dev' export const BSKY_SERVICE = 'https://bsky.social' +export const BSKY_SERVICE_DID = 'did:web:bsky.social' export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app' export const DEFAULT_SERVICE = BSKY_SERVICE const HELP_DESK_LANG = 'en-us' @@ -31,7 +32,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = { 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol 'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me 'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social - 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social + 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // darrin.bsky.team } const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c3bd1a7cb..66134a462 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -5,6 +5,7 @@ export type Gate = | 'debug_subscriptions' | 'disable_onboarding_policy_update_notice' | 'explore_show_suggested_feeds' + | 'handle_suggestions' | 'old_postonboarding' | 'onboarding_add_video_feed' | 'post_threads_v2_unspecced' diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index 78a2e1a09..02b9943d3 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -34,7 +34,8 @@ export function sanitizeHandle(handle: string, prefix = ''): string { export interface IsValidHandle { handleChars: boolean hyphenStartOrEnd: boolean - frontLength: boolean + frontLengthNotTooShort: boolean + frontLengthNotTooLong: boolean totalLength: boolean overall: boolean } @@ -50,7 +51,8 @@ export function validateServiceHandle( handleChars: !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), - frontLength: str.length >= 3 && str.length <= MAX_SERVICE_HANDLE_LENGTH, + frontLengthNotTooShort: str.length >= 3, + frontLengthNotTooLong: str.length <= MAX_SERVICE_HANDLE_LENGTH, totalLength: fullHandle.length <= 253, } diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index dfca1f7d8..0c9ea1ef6 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -67,7 +67,9 @@ export type MetricEvents = { activeStep: number backgroundCount: number } - 'signup:handleTaken': {} + 'signup:handleTaken': {typeahead?: boolean} + 'signup:handleAvailable': {typeahead?: boolean} + 'signup:handleSuggestionSelected': {method: string} 'signin:hostingProviderPressed': { hostingProviderDidChange: boolean } 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/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index e2f249a13..8ea893c4a 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -144,7 +144,7 @@ function StepCaptchaInner({ return ( <ScreenTransition> - <View style={[a.gap_lg]}> + <View style={[a.gap_lg, a.pt_lg]}> <View style={[ a.w_full, 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/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 50cc5aa26..807bbff4f 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -157,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 && @@ -167,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/state/queries/handle-availability.ts b/src/state/queries/handle-availability.ts new file mode 100644 index 000000000..9391f5d09 --- /dev/null +++ b/src/state/queries/handle-availability.ts @@ -0,0 +1,126 @@ +import {Agent, ComAtprotoTempCheckHandleAvailability} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +import { + BSKY_SERVICE, + BSKY_SERVICE_DID, + PUBLIC_BSKY_SERVICE, +} from '#/lib/constants' +import {createFullHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {useDebouncedValue} from '#/components/live/utils' +import * as bsky from '#/types/bsky' + +export const RQKEY_handleAvailability = ( + handle: string, + domain: string, + serviceDid: string, +) => ['handle-availability', {handle, domain, serviceDid}] + +export function useHandleAvailabilityQuery( + { + username, + serviceDomain, + serviceDid, + enabled, + birthDate, + email, + }: { + username: string + serviceDomain: string + serviceDid: string + enabled: boolean + birthDate?: string + email?: string + }, + debounceDelayMs = 500, +) { + const name = username.trim() + const debouncedHandle = useDebouncedValue(name, debounceDelayMs) + + return { + debouncedUsername: debouncedHandle, + enabled: enabled && name === debouncedHandle, + query: useQuery({ + enabled: enabled && name === debouncedHandle, + queryKey: RQKEY_handleAvailability( + debouncedHandle, + serviceDomain, + serviceDid, + ), + queryFn: async () => { + const handle = createFullHandle(name, serviceDomain) + return await checkHandleAvailability(handle, serviceDid, { + email, + birthDate, + typeahead: true, + }) + }, + }), + } +} + +export async function checkHandleAvailability( + handle: string, + serviceDid: string, + { + email, + birthDate, + typeahead, + }: { + email?: string + birthDate?: string + typeahead?: boolean + }, +) { + if (serviceDid === BSKY_SERVICE_DID) { + const agent = new Agent({service: BSKY_SERVICE}) + // entryway has a special API for handle availability + const {data} = await agent.com.atproto.temp.checkHandleAvailability({ + handle, + birthDate, + email, + }) + + if ( + bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultAvailable>( + data.result, + ComAtprotoTempCheckHandleAvailability.isResultAvailable, + ) + ) { + logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) + + return {available: true} as const + } else if ( + bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>( + data.result, + ComAtprotoTempCheckHandleAvailability.isResultUnavailable, + ) + ) { + logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) + return { + available: false, + suggestions: data.result.suggestions, + } as const + } else { + throw new Error( + `Unexpected result of \`checkHandleAvailability\`: ${JSON.stringify(data.result)}`, + ) + } + } else { + // 3rd party PDSes won't have this API so just try and resolve the handle + const agent = new Agent({service: PUBLIC_BSKY_SERVICE}) + try { + const res = await agent.resolveHandle({ + handle, + }) + + if (res.data.did) { + logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) + return {available: false} as const + } + } catch {} + logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) + return {available: true} as const + } +} diff --git a/yarn.lock b/yarn.lock index 3cd6abc12..c6ef295ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,10 +63,10 @@ "@atproto/xrpc" "^0.7.1" "@atproto/xrpc-server" "^0.9.1" -"@atproto/api@^0.15.26": - version "0.15.26" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52" - integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA== +"@atproto/api@^0.16.2": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd" + integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ== dependencies: "@atproto/common-web" "^0.4.2" "@atproto/lexicon" "^0.4.12" |