Bluesky app fork with some witchin' additions 💫

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>

authored by samuel.fm

surfdude29
Hailey
Eric Bailey
and committed by
GitHub
93c4719a f708e884

+593 -246
+5 -5
__tests__/lib/strings/handles.test.ts
··· 1 - import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' 1 + import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' 2 2 3 3 describe('handle validation', () => { 4 4 const valid = [ ··· 18 18 }) 19 19 20 20 const invalid = [ 21 - ['al', 'bsky.social', 'frontLength'], 21 + ['al', 'bsky.social', 'frontLengthNotTooShort'], 22 22 ['-alice', 'bsky.social', 'hyphenStartOrEnd'], 23 23 ['alice-', 'bsky.social', 'hyphenStartOrEnd'], 24 24 ['%%%', 'bsky.social', 'handleChars'], 25 - ['1234567890123456789', 'bsky.social', 'frontLength'], 25 + ['1234567890123456789', 'bsky.social', 'frontLengthNotTooLong'], 26 26 [ 27 27 '1234567890123456789', 28 28 'my-custom-pds-with-long-name.social', 29 - 'frontLength', 29 + 'frontLengthNotTooLong', 30 30 ], 31 - ['al', 'my-custom-pds-with-long-name.social', 'frontLength'], 31 + ['al', 'my-custom-pds-with-long-name.social', 'frontLengthNotTooShort'], 32 32 ['a'.repeat(300), 'toolong.com', 'totalLength'], 33 33 ] satisfies [string, string, keyof IsValidHandle][] 34 34 it.each(invalid)(
+1
assets/icons/circle_stroke2_corner0_rounded.svg
··· 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>
+1 -1
package.json
··· 70 70 "icons:optimize": "svgo -f ./assets/icons" 71 71 }, 72 72 "dependencies": { 73 - "@atproto/api": "^0.15.26", 73 + "@atproto/api": "^0.16.2", 74 74 "@bitdrift/react-native": "^0.6.8", 75 75 "@braintree/sanitize-url": "^6.0.2", 76 76 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+75 -9
src/components/forms/TextField.tsx
··· 1 - import React from 'react' 1 + import {createContext, useContext, useMemo, useRef} from 'react' 2 2 import { 3 3 type AccessibilityProps, 4 4 StyleSheet, ··· 16 16 applyFonts, 17 17 atoms as a, 18 18 ios, 19 + platform, 19 20 type TextStyleProp, 21 + tokens, 20 22 useAlf, 21 23 useTheme, 22 24 web, ··· 25 27 import {type Props as SVGIconProps} from '#/components/icons/common' 26 28 import {Text} from '#/components/Typography' 27 29 28 - const Context = React.createContext<{ 30 + const Context = createContext<{ 29 31 inputRef: React.RefObject<TextInput> | null 30 32 isInvalid: boolean 31 33 hovered: boolean ··· 48 50 export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}> 49 51 50 52 export function Root({children, isInvalid = false}: RootProps) { 51 - const inputRef = React.useRef<TextInput>(null) 53 + const inputRef = useRef<TextInput>(null) 52 54 const { 53 55 state: hovered, 54 56 onIn: onHoverIn, ··· 56 58 } = useInteractionState() 57 59 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 58 60 59 - const context = React.useMemo( 61 + const context = useMemo( 60 62 () => ({ 61 63 inputRef, 62 64 hovered, ··· 96 98 97 99 export function useSharedInputStyles() { 98 100 const t = useTheme() 99 - return React.useMemo(() => { 101 + return useMemo(() => { 100 102 const hover: ViewStyle[] = [ 101 103 { 102 104 borderColor: t.palette.contrast_100, ··· 158 160 }: InputProps) { 159 161 const t = useTheme() 160 162 const {fonts} = useAlf() 161 - const ctx = React.useContext(Context) 163 + const ctx = useContext(Context) 162 164 const withinRoot = Boolean(ctx.inputRef) 163 165 164 166 const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = ··· 283 285 284 286 export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) { 285 287 const t = useTheme() 286 - const ctx = React.useContext(Context) 287 - const {hover, focus, errorHover, errorFocus} = React.useMemo(() => { 288 + const ctx = useContext(Context) 289 + const {hover, focus, errorHover, errorFocus} = useMemo(() => { 288 290 const hover: TextStyle[] = [ 289 291 { 290 292 color: t.palette.contrast_800, ··· 342 344 } 343 345 >) { 344 346 const t = useTheme() 345 - const ctx = React.useContext(Context) 347 + const ctx = useContext(Context) 346 348 return ( 347 349 <Text 348 350 accessibilityLabel={label} ··· 362 364 </Text> 363 365 ) 364 366 } 367 + 368 + export function GhostText({ 369 + children, 370 + value, 371 + }: { 372 + children: string 373 + value: string 374 + }) { 375 + const t = useTheme() 376 + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 377 + return ( 378 + <View 379 + style={[ 380 + a.pointer_events_none, 381 + a.absolute, 382 + a.z_10, 383 + { 384 + paddingLeft: platform({ 385 + native: 386 + // input padding 387 + tokens.space.md + 388 + // icon 389 + tokens.space.xl + 390 + // icon padding 391 + tokens.space.xs + 392 + // text input padding 393 + tokens.space.xs, 394 + web: 395 + // icon 396 + tokens.space.xl + 397 + // icon padding 398 + tokens.space.xs + 399 + // text input padding 400 + tokens.space.xs, 401 + }), 402 + }, 403 + web(a.pr_md), 404 + a.overflow_hidden, 405 + a.max_w_full, 406 + ]} 407 + aria-hidden={true} 408 + accessibilityElementsHidden 409 + importantForAccessibility="no-hide-descendants"> 410 + <Text 411 + style={[ 412 + {color: 'transparent'}, 413 + a.text_md, 414 + {lineHeight: a.text_md.fontSize * 1.1875}, 415 + a.w_full, 416 + ]} 417 + numberOfLines={1}> 418 + {children} 419 + <Text 420 + style={[ 421 + t.atoms.text_contrast_low, 422 + a.text_md, 423 + {lineHeight: a.text_md.fontSize * 1.1875}, 424 + ]}> 425 + {value} 426 + </Text> 427 + </Text> 428 + </View> 429 + ) 430 + }
+5
src/components/icons/Circle.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Circle_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+2 -1
src/lib/constants.ts
··· 5 5 Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' 6 6 export const STAGING_SERVICE = 'https://staging.bsky.dev' 7 7 export const BSKY_SERVICE = 'https://bsky.social' 8 + export const BSKY_SERVICE_DID = 'did:web:bsky.social' 8 9 export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app' 9 10 export const DEFAULT_SERVICE = BSKY_SERVICE 10 11 const HELP_DESK_LANG = 'en-us' ··· 31 32 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol 32 33 'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me 33 34 'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social 34 - 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social 35 + 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // darrin.bsky.team 35 36 } 36 37 37 38 const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
+1
src/lib/statsig/gates.ts
··· 5 5 | 'debug_subscriptions' 6 6 | 'disable_onboarding_policy_update_notice' 7 7 | 'explore_show_suggested_feeds' 8 + | 'handle_suggestions' 8 9 | 'old_postonboarding' 9 10 | 'onboarding_add_video_feed' 10 11 | 'post_threads_v2_unspecced'
+4 -2
src/lib/strings/handles.ts
··· 34 34 export interface IsValidHandle { 35 35 handleChars: boolean 36 36 hyphenStartOrEnd: boolean 37 - frontLength: boolean 37 + frontLengthNotTooShort: boolean 38 + frontLengthNotTooLong: boolean 38 39 totalLength: boolean 39 40 overall: boolean 40 41 } ··· 50 51 handleChars: 51 52 !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), 52 53 hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), 53 - frontLength: str.length >= 3 && str.length <= MAX_SERVICE_HANDLE_LENGTH, 54 + frontLengthNotTooShort: str.length >= 3, 55 + frontLengthNotTooLong: str.length <= MAX_SERVICE_HANDLE_LENGTH, 54 56 totalLength: fullHandle.length <= 253, 55 57 } 56 58
+3 -1
src/logger/metrics.ts
··· 67 67 activeStep: number 68 68 backgroundCount: number 69 69 } 70 - 'signup:handleTaken': {} 70 + 'signup:handleTaken': {typeahead?: boolean} 71 + 'signup:handleAvailable': {typeahead?: boolean} 72 + 'signup:handleSuggestionSelected': {method: string} 71 73 'signin:hostingProviderPressed': { 72 74 hostingProviderDidChange: boolean 73 75 }
+1 -1
src/screens/Signup/BackNextButtons.tsx
··· 9 9 export interface BackNextButtonsProps { 10 10 hideNext?: boolean 11 11 showRetry?: boolean 12 - isLoading: boolean 12 + isLoading?: boolean 13 13 isNextDisabled?: boolean 14 14 onBackPress: () => void 15 15 onNextPress?: () => void
+1 -1
src/screens/Signup/StepCaptcha/index.tsx
··· 144 144 145 145 return ( 146 146 <ScreenTransition> 147 - <View style={[a.gap_lg]}> 147 + <View style={[a.gap_lg, a.pt_lg]}> 148 148 <View 149 149 style={[ 150 150 a.w_full,
-217
src/screens/Signup/StepHandle.tsx
··· 1 - import React, {useRef} from 'react' 2 - import {View} from 'react-native' 3 - import {msg, Plural, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import { 7 - createFullHandle, 8 - MAX_SERVICE_HANDLE_LENGTH, 9 - validateServiceHandle, 10 - } from '#/lib/strings/handles' 11 - import {logger} from '#/logger' 12 - import {useAgent} from '#/state/session' 13 - import {ScreenTransition} from '#/screens/Login/ScreenTransition' 14 - import {useSignupContext} from '#/screens/Signup/state' 15 - import {atoms as a, useTheme} from '#/alf' 16 - import * as TextField from '#/components/forms/TextField' 17 - import {useThrottledValue} from '#/components/hooks/useThrottledValue' 18 - import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 19 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 20 - import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 21 - import {Text} from '#/components/Typography' 22 - import {BackNextButtons} from './BackNextButtons' 23 - 24 - export function StepHandle() { 25 - const {_} = useLingui() 26 - const t = useTheme() 27 - const {state, dispatch} = useSignupContext() 28 - const agent = useAgent() 29 - const handleValueRef = useRef<string>(state.handle) 30 - const [draftValue, setDraftValue] = React.useState(state.handle) 31 - const isLoading = useThrottledValue(state.isLoading, 500) 32 - 33 - const onNextPress = React.useCallback(async () => { 34 - const handle = handleValueRef.current.trim() 35 - dispatch({ 36 - type: 'setHandle', 37 - value: handle, 38 - }) 39 - 40 - const newValidCheck = validateServiceHandle(handle, state.userDomain) 41 - if (!newValidCheck.overall) { 42 - return 43 - } 44 - 45 - try { 46 - dispatch({type: 'setIsLoading', value: true}) 47 - 48 - const res = await agent.resolveHandle({ 49 - handle: createFullHandle(handle, state.userDomain), 50 - }) 51 - 52 - if (res.data.did) { 53 - dispatch({ 54 - type: 'setError', 55 - value: _(msg`That handle is already taken.`), 56 - field: 'handle', 57 - }) 58 - logger.metric('signup:handleTaken', {}, {statsig: true}) 59 - return 60 - } 61 - } catch (e) { 62 - // Don't have to handle 63 - } finally { 64 - dispatch({type: 'setIsLoading', value: false}) 65 - } 66 - 67 - logger.metric( 68 - 'signup:nextPressed', 69 - { 70 - activeStep: state.activeStep, 71 - phoneVerificationRequired: 72 - state.serviceDescription?.phoneVerificationRequired, 73 - }, 74 - {statsig: true}, 75 - ) 76 - // phoneVerificationRequired is actually whether a captcha is required 77 - if (!state.serviceDescription?.phoneVerificationRequired) { 78 - dispatch({ 79 - type: 'submit', 80 - task: {verificationCode: undefined, mutableProcessed: false}, 81 - }) 82 - return 83 - } 84 - dispatch({type: 'next'}) 85 - }, [ 86 - _, 87 - dispatch, 88 - state.activeStep, 89 - state.serviceDescription?.phoneVerificationRequired, 90 - state.userDomain, 91 - agent, 92 - ]) 93 - 94 - const onBackPress = React.useCallback(() => { 95 - const handle = handleValueRef.current.trim() 96 - dispatch({ 97 - type: 'setHandle', 98 - value: handle, 99 - }) 100 - dispatch({type: 'prev'}) 101 - logger.metric( 102 - 'signup:backPressed', 103 - {activeStep: state.activeStep}, 104 - {statsig: true}, 105 - ) 106 - }, [dispatch, state.activeStep]) 107 - 108 - const validCheck = validateServiceHandle(draftValue, state.userDomain) 109 - return ( 110 - <ScreenTransition> 111 - <View style={[a.gap_lg]}> 112 - <View> 113 - <TextField.Root> 114 - <TextField.Icon icon={At} /> 115 - <TextField.Input 116 - testID="handleInput" 117 - onChangeText={val => { 118 - if (state.error) { 119 - dispatch({type: 'setError', value: ''}) 120 - } 121 - 122 - // These need to always be in sync. 123 - handleValueRef.current = val 124 - setDraftValue(val) 125 - }} 126 - label={_(msg`Type your desired username`)} 127 - defaultValue={draftValue} 128 - autoCapitalize="none" 129 - autoCorrect={false} 130 - autoFocus 131 - autoComplete="off" 132 - /> 133 - </TextField.Root> 134 - </View> 135 - {draftValue !== '' && ( 136 - <Text style={[a.text_md]}> 137 - <Trans> 138 - Your full username will be{' '} 139 - <Text style={[a.text_md, a.font_bold]}> 140 - @{createFullHandle(draftValue, state.userDomain)} 141 - </Text> 142 - </Trans> 143 - </Text> 144 - )} 145 - 146 - {draftValue !== '' && ( 147 - <View 148 - style={[ 149 - a.w_full, 150 - a.rounded_sm, 151 - a.border, 152 - a.p_md, 153 - a.gap_sm, 154 - t.atoms.border_contrast_low, 155 - ]}> 156 - {state.error ? ( 157 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 158 - <IsValidIcon valid={false} /> 159 - <Text style={[a.text_md, a.flex_1]}>{state.error}</Text> 160 - </View> 161 - ) : undefined} 162 - {validCheck.hyphenStartOrEnd ? ( 163 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 164 - <IsValidIcon valid={validCheck.handleChars} /> 165 - <Text style={[a.text_md, a.flex_1]}> 166 - <Trans>Only contains letters, numbers, and hyphens</Trans> 167 - </Text> 168 - </View> 169 - ) : ( 170 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 171 - <IsValidIcon valid={validCheck.hyphenStartOrEnd} /> 172 - <Text style={[a.text_md, a.flex_1]}> 173 - <Trans>Doesn't begin or end with a hyphen</Trans> 174 - </Text> 175 - </View> 176 - )} 177 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 178 - <IsValidIcon 179 - valid={validCheck.frontLength && validCheck.totalLength} 180 - /> 181 - {!validCheck.totalLength || 182 - draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? ( 183 - <Text style={[a.text_md, a.flex_1]}> 184 - <Trans> 185 - No longer than{' '} 186 - <Plural 187 - value={MAX_SERVICE_HANDLE_LENGTH} 188 - other="# characters" 189 - /> 190 - </Trans> 191 - </Text> 192 - ) : ( 193 - <Text style={[a.text_md, a.flex_1]}> 194 - <Trans>At least 3 characters</Trans> 195 - </Text> 196 - )} 197 - </View> 198 - </View> 199 - )} 200 - </View> 201 - <BackNextButtons 202 - isLoading={isLoading} 203 - isNextDisabled={!validCheck.overall} 204 - onBackPress={onBackPress} 205 - onNextPress={onNextPress} 206 - /> 207 - </ScreenTransition> 208 - ) 209 - } 210 - 211 - function IsValidIcon({valid}: {valid: boolean}) { 212 - const t = useTheme() 213 - if (!valid) { 214 - return <Times size="md" style={{color: t.palette.negative_500}} /> 215 - } 216 - return <Check size="md" style={{color: t.palette.positive_700}} /> 217 - }
+80
src/screens/Signup/StepHandle/HandleSuggestions.tsx
··· 1 + import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated' 2 + import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {atoms as a, native, useTheme} from '#/alf' 7 + import {borderRadius} from '#/alf/tokens' 8 + import {Button} from '#/components/Button' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function HandleSuggestions({ 12 + suggestions, 13 + onSelect, 14 + }: { 15 + suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[] 16 + onSelect: ( 17 + suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion, 18 + ) => void 19 + }) { 20 + const t = useTheme() 21 + const {_} = useLingui() 22 + 23 + return ( 24 + <Animated.View 25 + entering={native(FadeInDown.easing(Easing.out(Easing.exp)))} 26 + exiting={native(FadeOut)} 27 + style={[ 28 + a.flex_1, 29 + a.border, 30 + a.rounded_sm, 31 + t.atoms.shadow_sm, 32 + t.atoms.bg, 33 + t.atoms.border_contrast_low, 34 + a.mt_xs, 35 + a.z_50, 36 + a.w_full, 37 + a.zoom_fade_in, 38 + ]}> 39 + {suggestions.map((suggestion, index) => ( 40 + <Button 41 + label={_( 42 + msg({ 43 + message: `Select ${suggestion.handle}`, 44 + comment: `Accessibility label for a username suggestion in the account creation flow`, 45 + }), 46 + )} 47 + key={index} 48 + onPress={() => onSelect(suggestion)} 49 + hoverStyle={[t.atoms.bg_contrast_25]} 50 + style={[ 51 + a.w_full, 52 + a.flex_row, 53 + a.align_center, 54 + a.justify_between, 55 + a.p_md, 56 + a.border_b, 57 + t.atoms.border_contrast_low, 58 + index === 0 && { 59 + borderTopStartRadius: borderRadius.sm, 60 + borderTopEndRadius: borderRadius.sm, 61 + }, 62 + index === suggestions.length - 1 && [ 63 + { 64 + borderBottomStartRadius: borderRadius.sm, 65 + borderBottomEndRadius: borderRadius.sm, 66 + }, 67 + a.border_b_0, 68 + ], 69 + ]}> 70 + <Text style={[a.text_md]}>{suggestion.handle}</Text> 71 + <Text style={[a.text_sm, {color: t.palette.positive_700}]}> 72 + <Trans comment="Shown next to an available username suggestion in the account creation flow"> 73 + Available 74 + </Trans> 75 + </Text> 76 + </Button> 77 + ))} 78 + </Animated.View> 79 + ) 80 + }
+279
src/screens/Signup/StepHandle/index.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + LayoutAnimationConfig, 7 + LinearTransition, 8 + } from 'react-native-reanimated' 9 + import {msg, Plural, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {useGate} from '#/lib/statsig/statsig' 13 + import { 14 + createFullHandle, 15 + MAX_SERVICE_HANDLE_LENGTH, 16 + validateServiceHandle, 17 + } from '#/lib/strings/handles' 18 + import {logger} from '#/logger' 19 + import { 20 + checkHandleAvailability, 21 + useHandleAvailabilityQuery, 22 + } from '#/state/queries/handle-availability' 23 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 24 + import {useSignupContext} from '#/screens/Signup/state' 25 + import {atoms as a, native, useTheme} from '#/alf' 26 + import * as TextField from '#/components/forms/TextField' 27 + import {useThrottledValue} from '#/components/hooks/useThrottledValue' 28 + import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 29 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 30 + import {Text} from '#/components/Typography' 31 + import {IS_INTERNAL} from '#/env' 32 + import {BackNextButtons} from '../BackNextButtons' 33 + import {HandleSuggestions} from './HandleSuggestions' 34 + 35 + export function StepHandle() { 36 + const {_} = useLingui() 37 + const t = useTheme() 38 + const gate = useGate() 39 + const {state, dispatch} = useSignupContext() 40 + const [draftValue, setDraftValue] = useState(state.handle) 41 + const isNextLoading = useThrottledValue(state.isLoading, 500) 42 + 43 + const validCheck = validateServiceHandle(draftValue, state.userDomain) 44 + 45 + const { 46 + debouncedUsername: debouncedDraftValue, 47 + enabled: queryEnabled, 48 + query: {data: isHandleAvailable, isPending}, 49 + } = useHandleAvailabilityQuery({ 50 + username: draftValue, 51 + serviceDid: state.serviceDescription?.did ?? 'UNKNOWN', 52 + serviceDomain: state.userDomain, 53 + birthDate: state.dateOfBirth.toISOString(), 54 + email: state.email, 55 + enabled: validCheck.overall, 56 + }) 57 + 58 + const onNextPress = async () => { 59 + const handle = draftValue.trim() 60 + dispatch({ 61 + type: 'setHandle', 62 + value: handle, 63 + }) 64 + 65 + if (!validCheck.overall) { 66 + return 67 + } 68 + 69 + dispatch({type: 'setIsLoading', value: true}) 70 + 71 + try { 72 + const {available: handleAvailable} = await checkHandleAvailability( 73 + createFullHandle(handle, state.userDomain), 74 + state.serviceDescription?.did ?? 'UNKNOWN', 75 + {typeahead: false}, 76 + ) 77 + 78 + if (!handleAvailable) { 79 + dispatch({ 80 + type: 'setError', 81 + value: _(msg`That username is already taken`), 82 + field: 'handle', 83 + }) 84 + return 85 + } 86 + } catch (error) { 87 + logger.error('Failed to check handle availability on next press', { 88 + safeMessage: error, 89 + }) 90 + // do nothing on error, let them pass 91 + } finally { 92 + dispatch({type: 'setIsLoading', value: false}) 93 + } 94 + 95 + logger.metric( 96 + 'signup:nextPressed', 97 + { 98 + activeStep: state.activeStep, 99 + phoneVerificationRequired: 100 + state.serviceDescription?.phoneVerificationRequired, 101 + }, 102 + {statsig: true}, 103 + ) 104 + // phoneVerificationRequired is actually whether a captcha is required 105 + if (!state.serviceDescription?.phoneVerificationRequired) { 106 + dispatch({ 107 + type: 'submit', 108 + task: {verificationCode: undefined, mutableProcessed: false}, 109 + }) 110 + return 111 + } 112 + dispatch({type: 'next'}) 113 + } 114 + 115 + const onBackPress = () => { 116 + const handle = draftValue.trim() 117 + dispatch({ 118 + type: 'setHandle', 119 + value: handle, 120 + }) 121 + dispatch({type: 'prev'}) 122 + logger.metric( 123 + 'signup:backPressed', 124 + {activeStep: state.activeStep}, 125 + {statsig: true}, 126 + ) 127 + } 128 + 129 + const hasDebounceSettled = draftValue === debouncedDraftValue 130 + const isHandleTaken = 131 + !isPending && 132 + queryEnabled && 133 + isHandleAvailable && 134 + !isHandleAvailable.available 135 + const isNotReady = isPending || !hasDebounceSettled 136 + const isNextDisabled = 137 + !validCheck.overall || !!state.error || isNotReady ? true : isHandleTaken 138 + 139 + const textFieldInvalid = 140 + isHandleTaken || 141 + !validCheck.frontLengthNotTooLong || 142 + !validCheck.handleChars || 143 + !validCheck.hyphenStartOrEnd || 144 + !validCheck.totalLength 145 + 146 + return ( 147 + <ScreenTransition> 148 + <View style={[a.gap_sm, a.pt_lg, a.z_10]}> 149 + <View> 150 + <TextField.Root isInvalid={textFieldInvalid}> 151 + <TextField.Icon icon={AtIcon} /> 152 + <TextField.Input 153 + testID="handleInput" 154 + onChangeText={val => { 155 + if (state.error) { 156 + dispatch({type: 'setError', value: ''}) 157 + } 158 + setDraftValue(val.toLocaleLowerCase()) 159 + }} 160 + label={state.userDomain} 161 + value={draftValue} 162 + keyboardType="ascii-capable" // fix for iOS replacing -- with — 163 + autoCapitalize="none" 164 + autoCorrect={false} 165 + autoFocus 166 + autoComplete="off" 167 + /> 168 + {draftValue.length > 0 && ( 169 + <TextField.GhostText value={state.userDomain}> 170 + {draftValue} 171 + </TextField.GhostText> 172 + )} 173 + {isHandleAvailable?.available && ( 174 + <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} /> 175 + )} 176 + </TextField.Root> 177 + </View> 178 + <LayoutAnimationConfig skipEntering skipExiting> 179 + <View style={[a.gap_xs]}> 180 + {state.error && ( 181 + <Requirement> 182 + <RequirementText>{state.error}</RequirementText> 183 + </Requirement> 184 + )} 185 + {isHandleTaken && validCheck.overall && ( 186 + <> 187 + <Requirement> 188 + <RequirementText> 189 + <Trans> 190 + {createFullHandle(draftValue, state.userDomain)} is not 191 + available 192 + </Trans> 193 + </RequirementText> 194 + </Requirement> 195 + {isHandleAvailable.suggestions && 196 + isHandleAvailable.suggestions.length > 0 && 197 + (gate('handle_suggestions') || IS_INTERNAL) && ( 198 + <HandleSuggestions 199 + suggestions={isHandleAvailable.suggestions} 200 + onSelect={suggestion => { 201 + setDraftValue( 202 + suggestion.handle.slice( 203 + 0, 204 + state.userDomain.length * -1, 205 + ), 206 + ) 207 + logger.metric('signup:handleSuggestionSelected', { 208 + method: suggestion.method, 209 + }) 210 + }} 211 + /> 212 + )} 213 + </> 214 + )} 215 + {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && ( 216 + <Requirement> 217 + {!validCheck.hyphenStartOrEnd ? ( 218 + <RequirementText> 219 + <Trans>Username cannot begin or end with a hyphen</Trans> 220 + </RequirementText> 221 + ) : ( 222 + <RequirementText> 223 + <Trans> 224 + Username must only contain letters (a-z), numbers, and 225 + hyphens 226 + </Trans> 227 + </RequirementText> 228 + )} 229 + </Requirement> 230 + )} 231 + <Requirement> 232 + {(!validCheck.frontLengthNotTooLong || 233 + !validCheck.totalLength) && ( 234 + <RequirementText> 235 + <Trans> 236 + Username cannot be longer than{' '} 237 + <Plural 238 + value={MAX_SERVICE_HANDLE_LENGTH} 239 + other="# characters" 240 + /> 241 + </Trans> 242 + </RequirementText> 243 + )} 244 + </Requirement> 245 + </View> 246 + </LayoutAnimationConfig> 247 + </View> 248 + <Animated.View layout={native(LinearTransition)}> 249 + <BackNextButtons 250 + isLoading={isNextLoading} 251 + isNextDisabled={isNextDisabled} 252 + onBackPress={onBackPress} 253 + onNextPress={onNextPress} 254 + /> 255 + </Animated.View> 256 + </ScreenTransition> 257 + ) 258 + } 259 + 260 + function Requirement({children}: {children: React.ReactNode}) { 261 + return ( 262 + <Animated.View 263 + style={[a.w_full]} 264 + layout={native(LinearTransition)} 265 + entering={native(FadeIn)} 266 + exiting={native(FadeOut)}> 267 + {children} 268 + </Animated.View> 269 + ) 270 + } 271 + 272 + function RequirementText({children}: {children: React.ReactNode}) { 273 + const t = useTheme() 274 + return ( 275 + <Text style={[a.text_sm, a.flex_1, {color: t.palette.negative_500}]}> 276 + {children} 277 + </Text> 278 + ) 279 + }
+1 -1
src/screens/Signup/StepInfo/index.tsx
··· 144 144 145 145 return ( 146 146 <ScreenTransition> 147 - <View style={[a.gap_md]}> 147 + <View style={[a.gap_md, a.pt_lg]}> 148 148 <FormError error={state.error} /> 149 149 <HostingProvider 150 150 minimal
+4 -3
src/screens/Signup/index.tsx
··· 157 157 a.pt_2xl, 158 158 !gtMobile && {paddingBottom: 100}, 159 159 ]}> 160 - <View style={[a.gap_sm, a.pb_3xl]}> 161 - <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 160 + <View style={[a.gap_sm, a.pb_sm]}> 161 + <Text 162 + style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}> 162 163 <Trans> 163 164 Step {state.activeStep + 1} of{' '} 164 165 {state.serviceDescription && ··· 167 168 : '3'} 168 169 </Trans> 169 170 </Text> 170 - <Text style={[a.text_3xl, a.font_bold]}> 171 + <Text style={[a.text_3xl, a.font_heavy]}> 171 172 {state.activeStep === SignupStep.INFO ? ( 172 173 <Trans>Your account</Trans> 173 174 ) : state.activeStep === SignupStep.HANDLE ? (
+126
src/state/queries/handle-availability.ts
··· 1 + import {Agent, ComAtprotoTempCheckHandleAvailability} from '@atproto/api' 2 + import {useQuery} from '@tanstack/react-query' 3 + 4 + import { 5 + BSKY_SERVICE, 6 + BSKY_SERVICE_DID, 7 + PUBLIC_BSKY_SERVICE, 8 + } from '#/lib/constants' 9 + import {createFullHandle} from '#/lib/strings/handles' 10 + import {logger} from '#/logger' 11 + import {useDebouncedValue} from '#/components/live/utils' 12 + import * as bsky from '#/types/bsky' 13 + 14 + export const RQKEY_handleAvailability = ( 15 + handle: string, 16 + domain: string, 17 + serviceDid: string, 18 + ) => ['handle-availability', {handle, domain, serviceDid}] 19 + 20 + export function useHandleAvailabilityQuery( 21 + { 22 + username, 23 + serviceDomain, 24 + serviceDid, 25 + enabled, 26 + birthDate, 27 + email, 28 + }: { 29 + username: string 30 + serviceDomain: string 31 + serviceDid: string 32 + enabled: boolean 33 + birthDate?: string 34 + email?: string 35 + }, 36 + debounceDelayMs = 500, 37 + ) { 38 + const name = username.trim() 39 + const debouncedHandle = useDebouncedValue(name, debounceDelayMs) 40 + 41 + return { 42 + debouncedUsername: debouncedHandle, 43 + enabled: enabled && name === debouncedHandle, 44 + query: useQuery({ 45 + enabled: enabled && name === debouncedHandle, 46 + queryKey: RQKEY_handleAvailability( 47 + debouncedHandle, 48 + serviceDomain, 49 + serviceDid, 50 + ), 51 + queryFn: async () => { 52 + const handle = createFullHandle(name, serviceDomain) 53 + return await checkHandleAvailability(handle, serviceDid, { 54 + email, 55 + birthDate, 56 + typeahead: true, 57 + }) 58 + }, 59 + }), 60 + } 61 + } 62 + 63 + export async function checkHandleAvailability( 64 + handle: string, 65 + serviceDid: string, 66 + { 67 + email, 68 + birthDate, 69 + typeahead, 70 + }: { 71 + email?: string 72 + birthDate?: string 73 + typeahead?: boolean 74 + }, 75 + ) { 76 + if (serviceDid === BSKY_SERVICE_DID) { 77 + const agent = new Agent({service: BSKY_SERVICE}) 78 + // entryway has a special API for handle availability 79 + const {data} = await agent.com.atproto.temp.checkHandleAvailability({ 80 + handle, 81 + birthDate, 82 + email, 83 + }) 84 + 85 + if ( 86 + bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultAvailable>( 87 + data.result, 88 + ComAtprotoTempCheckHandleAvailability.isResultAvailable, 89 + ) 90 + ) { 91 + logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 92 + 93 + return {available: true} as const 94 + } else if ( 95 + bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>( 96 + data.result, 97 + ComAtprotoTempCheckHandleAvailability.isResultUnavailable, 98 + ) 99 + ) { 100 + logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 101 + return { 102 + available: false, 103 + suggestions: data.result.suggestions, 104 + } as const 105 + } else { 106 + throw new Error( 107 + `Unexpected result of \`checkHandleAvailability\`: ${JSON.stringify(data.result)}`, 108 + ) 109 + } 110 + } else { 111 + // 3rd party PDSes won't have this API so just try and resolve the handle 112 + const agent = new Agent({service: PUBLIC_BSKY_SERVICE}) 113 + try { 114 + const res = await agent.resolveHandle({ 115 + handle, 116 + }) 117 + 118 + if (res.data.did) { 119 + logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 120 + return {available: false} as const 121 + } 122 + } catch {} 123 + logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 124 + return {available: true} as const 125 + } 126 + }
+4 -4
yarn.lock
··· 63 63 "@atproto/xrpc" "^0.7.1" 64 64 "@atproto/xrpc-server" "^0.9.1" 65 65 66 - "@atproto/api@^0.15.26": 67 - version "0.15.26" 68 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52" 69 - integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA== 66 + "@atproto/api@^0.16.2": 67 + version "0.16.2" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd" 69 + integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ== 70 70 dependencies: 71 71 "@atproto/common-web" "^0.4.2" 72 72 "@atproto/lexicon" "^0.4.12"