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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
|
import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
|
||||||
|
|
||||||
describe('handle validation', () => {
|
describe('handle validation', () => {
|
||||||
const valid = [
|
const valid = [
|
||||||
@@ -18,17 +18,17 @@ describe('handle validation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const invalid = [
|
const invalid = [
|
||||||
['al', 'bsky.social', 'frontLength'],
|
['al', 'bsky.social', 'frontLengthNotTooShort'],
|
||||||
['-alice', 'bsky.social', 'hyphenStartOrEnd'],
|
['-alice', 'bsky.social', 'hyphenStartOrEnd'],
|
||||||
['alice-', 'bsky.social', 'hyphenStartOrEnd'],
|
['alice-', 'bsky.social', 'hyphenStartOrEnd'],
|
||||||
['%%%', 'bsky.social', 'handleChars'],
|
['%%%', 'bsky.social', 'handleChars'],
|
||||||
['1234567890123456789', 'bsky.social', 'frontLength'],
|
['1234567890123456789', 'bsky.social', 'frontLengthNotTooLong'],
|
||||||
[
|
[
|
||||||
'1234567890123456789',
|
'1234567890123456789',
|
||||||
'my-custom-pds-with-long-name.social',
|
'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'],
|
['a'.repeat(300), 'toolong.com', 'totalLength'],
|
||||||
] satisfies [string, string, keyof IsValidHandle][]
|
] satisfies [string, string, keyof IsValidHandle][]
|
||||||
it.each(invalid)(
|
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
@@ -70,7 +70,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.26",
|
"@atproto/api": "^0.16.2",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import {createContext, useContext, useMemo, useRef} from 'react'
|
||||||
import {
|
import {
|
||||||
type AccessibilityProps,
|
type AccessibilityProps,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
applyFonts,
|
applyFonts,
|
||||||
atoms as a,
|
atoms as a,
|
||||||
ios,
|
ios,
|
||||||
|
platform,
|
||||||
type TextStyleProp,
|
type TextStyleProp,
|
||||||
|
tokens,
|
||||||
useAlf,
|
useAlf,
|
||||||
useTheme,
|
useTheme,
|
||||||
web,
|
web,
|
||||||
@@ -25,7 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = createContext<{
|
||||||
inputRef: React.RefObject<TextInput> | null
|
inputRef: React.RefObject<TextInput> | null
|
||||||
isInvalid: boolean
|
isInvalid: boolean
|
||||||
hovered: boolean
|
hovered: boolean
|
||||||
@@ -48,7 +50,7 @@ const Context = React.createContext<{
|
|||||||
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
||||||
|
|
||||||
export function Root({children, isInvalid = false}: RootProps) {
|
export function Root({children, isInvalid = false}: RootProps) {
|
||||||
const inputRef = React.useRef<TextInput>(null)
|
const inputRef = useRef<TextInput>(null)
|
||||||
const {
|
const {
|
||||||
state: hovered,
|
state: hovered,
|
||||||
onIn: onHoverIn,
|
onIn: onHoverIn,
|
||||||
@@ -56,7 +58,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
|||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
|
||||||
const context = React.useMemo(
|
const context = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
inputRef,
|
inputRef,
|
||||||
hovered,
|
hovered,
|
||||||
@@ -96,7 +98,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
|||||||
|
|
||||||
export function useSharedInputStyles() {
|
export function useSharedInputStyles() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return React.useMemo(() => {
|
return useMemo(() => {
|
||||||
const hover: ViewStyle[] = [
|
const hover: ViewStyle[] = [
|
||||||
{
|
{
|
||||||
borderColor: t.palette.contrast_100,
|
borderColor: t.palette.contrast_100,
|
||||||
@@ -158,7 +160,7 @@ export function createInput(Component: typeof TextInput) {
|
|||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {fonts} = useAlf()
|
const {fonts} = useAlf()
|
||||||
const ctx = React.useContext(Context)
|
const ctx = useContext(Context)
|
||||||
const withinRoot = Boolean(ctx.inputRef)
|
const withinRoot = Boolean(ctx.inputRef)
|
||||||
|
|
||||||
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
||||||
@@ -283,8 +285,8 @@ export function LabelText({
|
|||||||
|
|
||||||
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ctx = React.useContext(Context)
|
const ctx = useContext(Context)
|
||||||
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
|
const {hover, focus, errorHover, errorFocus} = useMemo(() => {
|
||||||
const hover: TextStyle[] = [
|
const hover: TextStyle[] = [
|
||||||
{
|
{
|
||||||
color: t.palette.contrast_800,
|
color: t.palette.contrast_800,
|
||||||
@@ -342,7 +344,7 @@ export function SuffixText({
|
|||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ctx = React.useContext(Context)
|
const ctx = useContext(Context)
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
@@ -362,3 +364,67 @@ export function SuffixText({
|
|||||||
</Text>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ export const LOCAL_DEV_SERVICE =
|
|||||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||||
export const BSKY_SERVICE = 'https://bsky.social'
|
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 PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||||
const HELP_DESK_LANG = 'en-us'
|
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:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
|
||||||
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
||||||
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
|
'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`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type Gate =
|
|||||||
| 'debug_subscriptions'
|
| 'debug_subscriptions'
|
||||||
| 'disable_onboarding_policy_update_notice'
|
| 'disable_onboarding_policy_update_notice'
|
||||||
| 'explore_show_suggested_feeds'
|
| 'explore_show_suggested_feeds'
|
||||||
|
| 'handle_suggestions'
|
||||||
| 'old_postonboarding'
|
| 'old_postonboarding'
|
||||||
| 'onboarding_add_video_feed'
|
| 'onboarding_add_video_feed'
|
||||||
| 'post_threads_v2_unspecced'
|
| 'post_threads_v2_unspecced'
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
|
|||||||
export interface IsValidHandle {
|
export interface IsValidHandle {
|
||||||
handleChars: boolean
|
handleChars: boolean
|
||||||
hyphenStartOrEnd: boolean
|
hyphenStartOrEnd: boolean
|
||||||
frontLength: boolean
|
frontLengthNotTooShort: boolean
|
||||||
|
frontLengthNotTooLong: boolean
|
||||||
totalLength: boolean
|
totalLength: boolean
|
||||||
overall: boolean
|
overall: boolean
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,8 @@ export function validateServiceHandle(
|
|||||||
handleChars:
|
handleChars:
|
||||||
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
||||||
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
|
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,
|
totalLength: fullHandle.length <= 253,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export type MetricEvents = {
|
|||||||
activeStep: number
|
activeStep: number
|
||||||
backgroundCount: number
|
backgroundCount: number
|
||||||
}
|
}
|
||||||
'signup:handleTaken': {}
|
'signup:handleTaken': {typeahead?: boolean}
|
||||||
|
'signup:handleAvailable': {typeahead?: boolean}
|
||||||
|
'signup:handleSuggestionSelected': {method: string}
|
||||||
'signin:hostingProviderPressed': {
|
'signin:hostingProviderPressed': {
|
||||||
hostingProviderDidChange: boolean
|
hostingProviderDidChange: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {Loader} from '#/components/Loader'
|
|||||||
export interface BackNextButtonsProps {
|
export interface BackNextButtonsProps {
|
||||||
hideNext?: boolean
|
hideNext?: boolean
|
||||||
showRetry?: boolean
|
showRetry?: boolean
|
||||||
isLoading: boolean
|
isLoading?: boolean
|
||||||
isNextDisabled?: boolean
|
isNextDisabled?: boolean
|
||||||
onBackPress: () => void
|
onBackPress: () => void
|
||||||
onNextPress?: () => void
|
onNextPress?: () => void
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function StepCaptchaInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenTransition>
|
<ScreenTransition>
|
||||||
<View style={[a.gap_lg]}>
|
<View style={[a.gap_lg, a.pt_lg]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ export function StepInfo({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenTransition>
|
<ScreenTransition>
|
||||||
<View style={[a.gap_md]}>
|
<View style={[a.gap_md, a.pt_lg]}>
|
||||||
<FormError error={state.error} />
|
<FormError error={state.error} />
|
||||||
<HostingProvider
|
<HostingProvider
|
||||||
minimal
|
minimal
|
||||||
|
|||||||
@@ -157,8 +157,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
a.pt_2xl,
|
a.pt_2xl,
|
||||||
!gtMobile && {paddingBottom: 100},
|
!gtMobile && {paddingBottom: 100},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.gap_sm, a.pb_3xl]}>
|
<View style={[a.gap_sm, a.pb_sm]}>
|
||||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
<Text
|
||||||
|
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Step {state.activeStep + 1} of{' '}
|
Step {state.activeStep + 1} of{' '}
|
||||||
{state.serviceDescription &&
|
{state.serviceDescription &&
|
||||||
@@ -167,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
: '3'}
|
: '3'}
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.text_3xl, a.font_bold]}>
|
<Text style={[a.text_3xl, a.font_heavy]}>
|
||||||
{state.activeStep === SignupStep.INFO ? (
|
{state.activeStep === SignupStep.INFO ? (
|
||||||
<Trans>Your account</Trans>
|
<Trans>Your account</Trans>
|
||||||
) : state.activeStep === SignupStep.HANDLE ? (
|
) : state.activeStep === SignupStep.HANDLE ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,10 +63,10 @@
|
|||||||
"@atproto/xrpc" "^0.7.1"
|
"@atproto/xrpc" "^0.7.1"
|
||||||
"@atproto/xrpc-server" "^0.9.1"
|
"@atproto/xrpc-server" "^0.9.1"
|
||||||
|
|
||||||
"@atproto/api@^0.15.26":
|
"@atproto/api@^0.16.2":
|
||||||
version "0.15.26"
|
version "0.16.2"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd"
|
||||||
integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA==
|
integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.2"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/lexicon" "^0.4.12"
|
"@atproto/lexicon" "^0.4.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user