diff --git a/__tests__/lib/strings/handles.test.ts b/__tests__/lib/strings/handles.test.ts
index 4456fae946..f3b289afdf 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 0000000000..98fe755b09
--- /dev/null
+++ b/assets/icons/circle_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index dc8888860f..2b44ead9f2 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 9b7ada3199..3913c32831 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 | 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(null)
+ const inputRef = useRef(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}) {
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 (
)
}
+
+export function GhostText({
+ children,
+ value,
+}: {
+ children: string
+ value: string
+}) {
+ const t = useTheme()
+ // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
+ return (
+
+
+ {children}
+
+ {value}
+
+
+
+ )
+}
diff --git a/src/components/icons/Circle.tsx b/src/components/icons/Circle.tsx
new file mode 100644
index 0000000000..93d837119e
--- /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 ab52b87107..21f0ab870e 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 = {
'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 c3bd1a7cbf..66134a4629 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 78a2e1a09a..02b9943d31 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 dfca1f7d8e..0c9ea1ef63 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 888b9071e1..5a85a85d1f 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 e2f249a13f..8ea893c4aa 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -144,7 +144,7 @@ function StepCaptchaInner({
return (
-
+
(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 (
-
-
-
-
-
- {
- 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"
- />
-
-
- {draftValue !== '' && (
-
-
- Your full username will be{' '}
-
- @{createFullHandle(draftValue, state.userDomain)}
-
-
-
- )}
-
- {draftValue !== '' && (
-
- {state.error ? (
-
-
- {state.error}
-
- ) : undefined}
- {validCheck.hyphenStartOrEnd ? (
-
-
-
- Only contains letters, numbers, and hyphens
-
-
- ) : (
-
-
-
- Doesn't begin or end with a hyphen
-
-
- )}
-
-
- {!validCheck.totalLength ||
- draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? (
-
-
- No longer than{' '}
-
-
-
- ) : (
-
- At least 3 characters
-
- )}
-
-
- )}
-
-
-
- )
-}
-
-function IsValidIcon({valid}: {valid: boolean}) {
- const t = useTheme()
- if (!valid) {
- return
- }
- return
-}
diff --git a/src/screens/Signup/StepHandle/HandleSuggestions.tsx b/src/screens/Signup/StepHandle/HandleSuggestions.tsx
new file mode 100644
index 0000000000..3d219d8864
--- /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 (
+
+ {suggestions.map((suggestion, index) => (
+
+ ))}
+
+ )
+}
diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx
new file mode 100644
index 0000000000..aaab435ae8
--- /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 (
+
+
+
+
+
+ {
+ 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 && (
+
+ {draftValue}
+
+ )}
+ {isHandleAvailable?.available && (
+
+ )}
+
+
+
+
+ {state.error && (
+
+ {state.error}
+
+ )}
+ {isHandleTaken && validCheck.overall && (
+ <>
+
+
+
+ {createFullHandle(draftValue, state.userDomain)} is not
+ available
+
+
+
+ {isHandleAvailable.suggestions &&
+ isHandleAvailable.suggestions.length > 0 &&
+ (gate('handle_suggestions') || IS_INTERNAL) && (
+ {
+ setDraftValue(
+ suggestion.handle.slice(
+ 0,
+ state.userDomain.length * -1,
+ ),
+ )
+ logger.metric('signup:handleSuggestionSelected', {
+ method: suggestion.method,
+ })
+ }}
+ />
+ )}
+ >
+ )}
+ {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && (
+
+ {!validCheck.hyphenStartOrEnd ? (
+
+ Username cannot begin or end with a hyphen
+
+ ) : (
+
+
+ Username must only contain letters (a-z), numbers, and
+ hyphens
+
+
+ )}
+
+ )}
+
+ {(!validCheck.frontLengthNotTooLong ||
+ !validCheck.totalLength) && (
+
+
+ Username cannot be longer than{' '}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+function Requirement({children}: {children: React.ReactNode}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function RequirementText({children}: {children: React.ReactNode}) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
index f24cd0e45d..cf4a9297e2 100644
--- a/src/screens/Signup/StepInfo/index.tsx
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -144,7 +144,7 @@ export function StepInfo({
return (
-
+
void}) {
a.pt_2xl,
!gtMobile && {paddingBottom: 100},
]}>
-
-
+
+
Step {state.activeStep + 1} of{' '}
{state.serviceDescription &&
@@ -167,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
: '3'}
-
+
{state.activeStep === SignupStep.INFO ? (
Your account
) : 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 0000000000..9391f5d09a
--- /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(
+ data.result,
+ ComAtprotoTempCheckHandleAvailability.isResultAvailable,
+ )
+ ) {
+ logger.metric('signup:handleAvailable', {typeahead}, {statsig: true})
+
+ return {available: true} as const
+ } else if (
+ bsky.dangerousIsType(
+ 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 3cd6abc124..c6ef295ea7 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"