From 93c4719a2140070b33f69dd0f12b4de2619a25a6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 8 Aug 2025 01:01:01 +0300 Subject: 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 Co-authored-by: Eric Bailey --- __tests__/lib/strings/handles.test.ts | 10 +- assets/icons/circle_stroke2_corner0_rounded.svg | 1 + package.json | 2 +- src/components/forms/TextField.tsx | 84 ++++++- src/components/icons/Circle.tsx | 5 + src/lib/constants.ts | 3 +- src/lib/statsig/gates.ts | 1 + src/lib/strings/handles.ts | 6 +- src/logger/metrics.ts | 4 +- src/screens/Signup/BackNextButtons.tsx | 2 +- src/screens/Signup/StepCaptcha/index.tsx | 2 +- src/screens/Signup/StepHandle.tsx | 217 ---------------- .../Signup/StepHandle/HandleSuggestions.tsx | 80 ++++++ src/screens/Signup/StepHandle/index.tsx | 279 +++++++++++++++++++++ src/screens/Signup/StepInfo/index.tsx | 2 +- src/screens/Signup/index.tsx | 7 +- src/state/queries/handle-availability.ts | 126 ++++++++++ yarn.lock | 8 +- 18 files changed, 593 insertions(+), 246 deletions(-) create mode 100644 assets/icons/circle_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Circle.tsx delete mode 100644 src/screens/Signup/StepHandle.tsx create mode 100644 src/screens/Signup/StepHandle/HandleSuggestions.tsx create mode 100644 src/screens/Signup/StepHandle/index.tsx create mode 100644 src/state/queries/handle-availability.ts 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 @@ + 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 | 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 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 = { '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 ( - + (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 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 ( + + {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 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 ( + + + + + + { + 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 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 ( - + 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 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( + 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 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" -- cgit 1.4.1