Compare commits

...

23 Commits

Author SHA1 Message Date
Eric Bailey 7be75f6807 Enable for TF 2025-08-07 16:52:09 -05:00
Eric Bailey 731ee24176 Merge remote-tracking branch 'origin/main' into samuel/live-handle-checks
* origin/main:
  Fix e2e (#8794)
2025-08-07 16:43:59 -05:00
Eric Bailey 95a625819c Fix flashing next button 2025-08-07 16:18:21 -05:00
Hailey e648c3bff0 Merge branch 'main' into samuel/live-handle-checks 2025-08-07 13:10:47 -07:00
Samuel Newman 63d58b5a55 fix bad import 2025-08-06 21:30:55 +03:00
Samuel Newman f29998eb87 move files around to make clearer 2025-08-06 21:30:44 +03:00
Samuel Newman 0f11376751 add gate 2025-08-06 21:27:18 +03:00
Samuel Newman 2917baa12e fix bug with checkHandleAvailability 2025-08-05 11:57:06 +03:00
Samuel Newman 94d7236407 convert to lowercase 2025-08-05 11:45:01 +03:00
Samuel Newman 2ebf94eed8 update checks, disable button if unavailable 2025-08-05 11:42:51 +03:00
Samuel Newman 75793153fb Apply suggestions from code review
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-08-05 01:36:15 -07:00
Samuel Newman 7f9d53ced1 share logic between typeahead and next button 2025-08-04 14:58:57 +03:00
Samuel Newman fc96b8f8a0 improvements, metrics 2025-08-04 14:42:17 +03:00
Samuel Newman 8f88fd5b2f add suggestions 2025-08-04 13:52:40 +03:00
Samuel Newman b0e9545710 tweak styles to try and match latest 2025-08-04 13:52:40 +03:00
Samuel Newman 1d0e0c8b47 move ghosttext component to textfield 2025-08-04 13:52:40 +03:00
Samuel Newman acdd320e83 tweak input 2025-08-04 13:52:40 +03:00
Samuel Newman 8fc9bc4e35 change validation check name 2025-08-04 13:52:40 +03:00
Samuel Newman a55992c776 only check reserved handles for bsky.social, fix test 2025-08-04 13:52:40 +03:00
Samuel Newman 64aa88e8d9 fix overflow 2025-08-04 13:52:40 +03:00
Samuel Newman d43ab58edc add metric types 2025-08-04 13:52:40 +03:00
Samuel Newman 36bb6772d9 metrics 2025-08-04 13:52:40 +03:00
Samuel Newman fed21caad3 check handle as you type 2025-08-04 13:52:40 +03:00
18 changed files with 593 additions and 246 deletions
+5 -5
View File
@@ -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)(
@@ -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>

After

Width:  |  Height:  |  Size: 250 B

+1 -1
View File
@@ -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",
+75 -9
View File
@@ -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>
)
}
+5
View File
@@ -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',
})
+2 -1
View File
@@ -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`
+1
View File
@@ -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'
+4 -2
View File
@@ -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,
}
+3 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
-217
View File
@@ -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}} />
}
@@ -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>
)
}
+279
View File
@@ -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>
)
}
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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 ? (
+126
View File
@@ -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
}
}
+4 -4
View File
@@ -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"