Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be75f6807 | |||
| 731ee24176 | |||
| 95a625819c | |||
| e648c3bff0 | |||
| 63d58b5a55 | |||
| f29998eb87 | |||
| 0f11376751 | |||
| 2917baa12e | |||
| 94d7236407 | |||
| 2ebf94eed8 | |||
| 75793153fb | |||
| 7f9d53ced1 | |||
| fc96b8f8a0 | |||
| 8f88fd5b2f | |||
| b0e9545710 | |||
| 1d0e0c8b47 | |||
| acdd320e83 | |||
| 8fc9bc4e35 | |||
| a55992c776 | |||
| 64aa88e8d9 | |||
| d43ab58edc | |||
| 36bb6772d9 | |||
| fed21caad3 |
@@ -1,4 +1,4 @@
|
||||
import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
|
||||
import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
|
||||
|
||||
describe('handle validation', () => {
|
||||
const valid = [
|
||||
@@ -18,17 +18,17 @@ describe('handle validation', () => {
|
||||
})
|
||||
|
||||
const invalid = [
|
||||
['al', 'bsky.social', 'frontLength'],
|
||||
['al', 'bsky.social', 'frontLengthNotTooShort'],
|
||||
['-alice', 'bsky.social', 'hyphenStartOrEnd'],
|
||||
['alice-', 'bsky.social', 'hyphenStartOrEnd'],
|
||||
['%%%', 'bsky.social', 'handleChars'],
|
||||
['1234567890123456789', 'bsky.social', 'frontLength'],
|
||||
['1234567890123456789', 'bsky.social', 'frontLengthNotTooLong'],
|
||||
[
|
||||
'1234567890123456789',
|
||||
'my-custom-pds-with-long-name.social',
|
||||
'frontLength',
|
||||
'frontLengthNotTooLong',
|
||||
],
|
||||
['al', 'my-custom-pds-with-long-name.social', 'frontLength'],
|
||||
['al', 'my-custom-pds-with-long-name.social', 'frontLengthNotTooShort'],
|
||||
['a'.repeat(300), 'toolong.com', 'totalLength'],
|
||||
] satisfies [string, string, keyof IsValidHandle][]
|
||||
it.each(invalid)(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 250 B |
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useMemo, useRef} from 'react'
|
||||
import {
|
||||
type AccessibilityProps,
|
||||
StyleSheet,
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
applyFonts,
|
||||
atoms as a,
|
||||
ios,
|
||||
platform,
|
||||
type TextStyleProp,
|
||||
tokens,
|
||||
useAlf,
|
||||
useTheme,
|
||||
web,
|
||||
@@ -25,7 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const Context = React.createContext<{
|
||||
const Context = createContext<{
|
||||
inputRef: React.RefObject<TextInput> | null
|
||||
isInvalid: boolean
|
||||
hovered: boolean
|
||||
@@ -48,7 +50,7 @@ const Context = React.createContext<{
|
||||
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
||||
|
||||
export function Root({children, isInvalid = false}: RootProps) {
|
||||
const inputRef = React.useRef<TextInput>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
@@ -56,7 +58,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
inputRef,
|
||||
hovered,
|
||||
@@ -96,7 +98,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||
|
||||
export function useSharedInputStyles() {
|
||||
const t = useTheme()
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
const hover: ViewStyle[] = [
|
||||
{
|
||||
borderColor: t.palette.contrast_100,
|
||||
@@ -158,7 +160,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
const {fonts} = useAlf()
|
||||
const ctx = React.useContext(Context)
|
||||
const ctx = useContext(Context)
|
||||
const withinRoot = Boolean(ctx.inputRef)
|
||||
|
||||
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
||||
@@ -283,8 +285,8 @@ export function LabelText({
|
||||
|
||||
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
const t = useTheme()
|
||||
const ctx = React.useContext(Context)
|
||||
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
|
||||
const ctx = useContext(Context)
|
||||
const {hover, focus, errorHover, errorFocus} = useMemo(() => {
|
||||
const hover: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.contrast_800,
|
||||
@@ -342,7 +344,7 @@ export function SuffixText({
|
||||
}
|
||||
>) {
|
||||
const t = useTheme()
|
||||
const ctx = React.useContext(Context)
|
||||
const ctx = useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
accessibilityLabel={label}
|
||||
@@ -362,3 +364,67 @@ export function SuffixText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function GhostText({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: string
|
||||
value: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.pointer_events_none,
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
{
|
||||
paddingLeft: platform({
|
||||
native:
|
||||
// input padding
|
||||
tokens.space.md +
|
||||
// icon
|
||||
tokens.space.xl +
|
||||
// icon padding
|
||||
tokens.space.xs +
|
||||
// text input padding
|
||||
tokens.space.xs,
|
||||
web:
|
||||
// icon
|
||||
tokens.space.xl +
|
||||
// icon padding
|
||||
tokens.space.xs +
|
||||
// text input padding
|
||||
tokens.space.xs,
|
||||
}),
|
||||
},
|
||||
web(a.pr_md),
|
||||
a.overflow_hidden,
|
||||
a.max_w_full,
|
||||
]}
|
||||
aria-hidden={true}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants">
|
||||
<Text
|
||||
style={[
|
||||
{color: 'transparent'},
|
||||
a.text_md,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
a.w_full,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{children}
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_low,
|
||||
a.text_md,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{value}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const BSKY_SERVICE_DID = 'did:web:bsky.social'
|
||||
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
@@ -31,7 +32,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
|
||||
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
|
||||
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
||||
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
|
||||
'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social
|
||||
'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // darrin.bsky.team
|
||||
}
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,7 +144,7 @@ function StepCaptchaInner({
|
||||
|
||||
return (
|
||||
<ScreenTransition>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_lg, a.pt_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
|
||||
@@ -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 (
|
||||
<ScreenTransition>
|
||||
<View style={[a.gap_md]}>
|
||||
<View style={[a.gap_md, a.pt_lg]}>
|
||||
<FormError error={state.error} />
|
||||
<HostingProvider
|
||||
minimal
|
||||
|
||||
@@ -157,8 +157,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
a.pt_2xl,
|
||||
!gtMobile && {paddingBottom: 100},
|
||||
]}>
|
||||
<View style={[a.gap_sm, a.pb_3xl]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<View style={[a.gap_sm, a.pb_sm]}>
|
||||
<Text
|
||||
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Step {state.activeStep + 1} of{' '}
|
||||
{state.serviceDescription &&
|
||||
@@ -167,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
: '3'}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
<Text style={[a.text_3xl, a.font_heavy]}>
|
||||
{state.activeStep === SignupStep.INFO ? (
|
||||
<Trans>Your account</Trans>
|
||||
) : state.activeStep === SignupStep.HANDLE ? (
|
||||
|
||||
@@ -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-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"
|
||||
|
||||
Reference in New Issue
Block a user