add suggestions
This commit is contained in:
@@ -5,7 +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_HANDLE_ENDSWITH = '.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'
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
|
||||
export * from './reserved'
|
||||
|
||||
const VALIDATE_REGEX =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,18 @@ import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeOut,
|
||||
FadeOutDown,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {BSKY_SERVICE_HANDLE_ENDSWITH} from '#/lib/constants'
|
||||
import {
|
||||
createFullHandle,
|
||||
isHandleReserved,
|
||||
MAX_SERVICE_HANDLE_LENGTH,
|
||||
validateServiceHandle,
|
||||
} from '#/lib/strings/handles'
|
||||
@@ -22,14 +23,18 @@ import {useAgent} from '#/state/session'
|
||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||
import {useSignupContext} from '#/screens/Signup/state'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {borderRadius} from '#/alf/tokens'
|
||||
import {Button} from '#/components/Button'
|
||||
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 {BackNextButtons} from './BackNextButtons'
|
||||
|
||||
export function StepHandle() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {state, dispatch} = useSignupContext()
|
||||
const agent = useAgent()
|
||||
const [draftValue, setDraftValue] = useState(state.handle)
|
||||
@@ -37,11 +42,14 @@ export function StepHandle() {
|
||||
|
||||
const validCheck = validateServiceHandle(draftValue, state.userDomain)
|
||||
|
||||
const {data: isHandleAvailable, isLoading} = useHandleAvailabilityQuery(
|
||||
draftValue,
|
||||
state.userDomain,
|
||||
validCheck.overall,
|
||||
)
|
||||
const {data: isHandleAvailable, isLoading} = useHandleAvailabilityQuery({
|
||||
username: draftValue,
|
||||
serviceDid: state.serviceDescription?.did ?? 'UNKNOWN',
|
||||
serviceDomain: state.userDomain,
|
||||
birthDate: state.dateOfBirth.toISOString(),
|
||||
email: state.email,
|
||||
enabled: validCheck.overall,
|
||||
})
|
||||
|
||||
const onNextPress = async () => {
|
||||
if (!isHandleAvailable?.available) return
|
||||
@@ -56,19 +64,6 @@ export function StepHandle() {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
state.userDomain === BSKY_SERVICE_HANDLE_ENDSWITH &&
|
||||
isHandleReserved(handle)
|
||||
) {
|
||||
dispatch({
|
||||
type: 'setError',
|
||||
value: _(msg`That username is not available`),
|
||||
field: 'handle',
|
||||
})
|
||||
logger.metric('signup:handleReserved', {}, {statsig: true})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch({type: 'setIsLoading', value: true})
|
||||
|
||||
@@ -159,6 +154,9 @@ export function StepHandle() {
|
||||
{draftValue}
|
||||
</TextField.GhostText>
|
||||
)}
|
||||
{isHandleAvailable?.available && (
|
||||
<CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} />
|
||||
)}
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
@@ -171,21 +169,31 @@ export function StepHandle() {
|
||||
{isHandleAvailable &&
|
||||
!isHandleAvailable.available &&
|
||||
validCheck.overall && (
|
||||
<Requirement>
|
||||
<RequirementText>
|
||||
{isHandleAvailable?.reason === 'taken' ? (
|
||||
<>
|
||||
<Requirement>
|
||||
<RequirementText>
|
||||
<Trans>
|
||||
{createFullHandle(draftValue, state.userDomain)} is
|
||||
taken
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{createFullHandle(draftValue, state.userDomain)} is
|
||||
invalid
|
||||
{createFullHandle(draftValue, state.userDomain)} is not
|
||||
available
|
||||
</Trans>
|
||||
</RequirementText>
|
||||
</Requirement>
|
||||
{isHandleAvailable.suggestions &&
|
||||
isHandleAvailable.suggestions.length > 0 && (
|
||||
<HandleSuggestions
|
||||
suggestions={isHandleAvailable.suggestions}
|
||||
onSelect={suggestion => {
|
||||
setDraftValue(
|
||||
suggestion.handle.slice(
|
||||
0,
|
||||
state.userDomain.length * -1,
|
||||
),
|
||||
)
|
||||
// TODO: add logging
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RequirementText>
|
||||
</Requirement>
|
||||
</>
|
||||
)}
|
||||
{(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && (
|
||||
<Requirement>
|
||||
@@ -247,3 +255,57 @@ function RequirementText({children}: {children: React.ReactNode}) {
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function HandleSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: {
|
||||
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
|
||||
onSelect: (
|
||||
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion,
|
||||
) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown}
|
||||
exiting={FadeOutDown}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.shadow_md,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<Button
|
||||
label={_(msg`Select ${suggestion.handle}`)}
|
||||
key={index}
|
||||
onPress={() => onSelect(suggestion)}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.px_md,
|
||||
a.py_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
index === 0 && {borderTopEndRadius: borderRadius.sm},
|
||||
index === suggestions.length - 1 && [
|
||||
{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>Available</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,113 @@
|
||||
import {useMemo} from 'react'
|
||||
import {Agent, ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {BSKY_SERVICE_HANDLE_ENDSWITH} from '#/lib/constants'
|
||||
import {createFullHandle, isHandleReserved} from '#/lib/strings/handles'
|
||||
import {
|
||||
BSKY_SERVICE,
|
||||
BSKY_SERVICE_DID,
|
||||
PUBLIC_BSKY_SERVICE,
|
||||
} from '#/lib/constants'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDebouncedValue} from '#/components/live/utils'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const RQKEY_handleAvailability = (handle: string) => [
|
||||
'handle-availability',
|
||||
handle,
|
||||
]
|
||||
export const RQKEY_handleAvailability = (
|
||||
handle: string,
|
||||
domain: string,
|
||||
serviceDid: string,
|
||||
) => ['handle-availability', handle, domain, serviceDid]
|
||||
|
||||
export function useHandleAvailabilityQuery(
|
||||
name: string,
|
||||
domain: string,
|
||||
enabled: boolean,
|
||||
{
|
||||
username,
|
||||
serviceDomain,
|
||||
serviceDid,
|
||||
enabled,
|
||||
birthDate,
|
||||
email,
|
||||
}: {
|
||||
username: string
|
||||
serviceDomain: string
|
||||
serviceDid: string
|
||||
enabled: boolean
|
||||
birthDate?: string
|
||||
email?: string
|
||||
},
|
||||
debounceDelayMs = 500,
|
||||
) {
|
||||
name = name.trim()
|
||||
const handle = createFullHandle(name, domain)
|
||||
const debouncedHandle = useDebouncedValue(handle, debounceDelayMs)
|
||||
const agent = useAgent()
|
||||
const name = username.trim()
|
||||
const debouncedHandle = useDebouncedValue(name, debounceDelayMs)
|
||||
const agent = useMemo(() => {
|
||||
if (serviceDid === BSKY_SERVICE_DID) {
|
||||
return new Agent({service: BSKY_SERVICE})
|
||||
} else {
|
||||
return new Agent({service: PUBLIC_BSKY_SERVICE})
|
||||
}
|
||||
}, [serviceDid])
|
||||
|
||||
return useQuery({
|
||||
enabled: enabled && handle === debouncedHandle,
|
||||
queryKey: RQKEY_handleAvailability(debouncedHandle),
|
||||
enabled: enabled && name === debouncedHandle,
|
||||
queryKey: RQKEY_handleAvailability(
|
||||
debouncedHandle,
|
||||
serviceDomain,
|
||||
serviceDid,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const frontSegment = debouncedHandle.split('.')[0]
|
||||
if (
|
||||
domain === BSKY_SERVICE_HANDLE_ENDSWITH &&
|
||||
isHandleReserved(frontSegment)
|
||||
) {
|
||||
const handle = createFullHandle(name, serviceDomain)
|
||||
if (serviceDid === BSKY_SERVICE_DID) {
|
||||
// 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,
|
||||
)
|
||||
) {
|
||||
return {available: true} as const
|
||||
} else if (
|
||||
bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>(
|
||||
data.result,
|
||||
ComAtprotoTempCheckHandleAvailability.isResultUnavailable,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
suggestions: data.result.suggestions,
|
||||
} as const
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unexpected result of \`checkHandleAvailability\`: ${JSON.stringify(data.result)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 3rd party services won't have this API so just try and resolve the handle
|
||||
try {
|
||||
const res = await agent.resolveHandle({
|
||||
handle,
|
||||
})
|
||||
|
||||
if (res.data.did) {
|
||||
logger.metric(
|
||||
'signup:handleReserved',
|
||||
{typeahead: true},
|
||||
{statsig: true},
|
||||
)
|
||||
return {available: false} as const
|
||||
}
|
||||
} catch {}
|
||||
logger.metric(
|
||||
'signup:handleReserved',
|
||||
'signup:handleAvailable',
|
||||
{typeahead: true},
|
||||
{statsig: true},
|
||||
)
|
||||
return {available: false, reason: 'reserved'} as const
|
||||
return {available: true} as const
|
||||
}
|
||||
try {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: debouncedHandle,
|
||||
})
|
||||
|
||||
if (res.data.did) {
|
||||
logger.metric(
|
||||
'signup:handleReserved',
|
||||
{typeahead: true},
|
||||
{statsig: true},
|
||||
)
|
||||
return {available: false, reason: 'taken'} as const
|
||||
}
|
||||
} catch {}
|
||||
logger.metric(
|
||||
'signup:handleAvailable',
|
||||
{typeahead: true},
|
||||
{statsig: true},
|
||||
)
|
||||
return {available: true} as const
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user