From 0e5b85a37d450a4338752937aa5bad1ed2dece6d Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sun, 31 May 2026 18:22:59 -0700 Subject: [PATCH] feat: typeahead at login (also a DID instead of handle.invalid?) --- __tests__/lib/strings/handles.test.ts | 26 ++- src/lib/routes/links.ts | 10 +- src/lib/strings/handles.ts | 11 ++ src/screens/Login/LoginForm.tsx | 40 ++-- src/screens/Login/LoginForm.web.tsx | 40 ++-- .../components/HandleAutocompleteInput.tsx | 187 ++++++++++++++++++ 6 files changed, 251 insertions(+), 63 deletions(-) create mode 100644 src/screens/Login/components/HandleAutocompleteInput.tsx diff --git a/__tests__/lib/strings/handles.test.ts b/__tests__/lib/strings/handles.test.ts index f3b289afd..ed79afd74 100644 --- a/__tests__/lib/strings/handles.test.ts +++ b/__tests__/lib/strings/handles.test.ts @@ -1,4 +1,28 @@ -import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' +import { + type IsValidHandle, + profileIdentifier, + validateServiceHandle, +} from '#/lib/strings/handles' + +describe('profileIdentifier', () => { + it('returns handle when valid', () => { + expect( + profileIdentifier({did: 'did:plc:abc', handle: 'alice.bsky.social'}), + ).toBe('alice.bsky.social') + }) + + it('returns did for handle.invalid', () => { + expect( + profileIdentifier({did: 'did:plc:abc', handle: 'handle.invalid'}), + ).toBe('did:plc:abc') + }) + + it('returns did for malformed handles', () => { + expect(profileIdentifier({did: 'did:plc:abc', handle: 'not a handle'})).toBe( + 'did:plc:abc', + ) + }) +}) describe('handle validation', () => { const valid = [ diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 1a15fbd07..6d10e3089 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -1,6 +1,6 @@ import {type AppBskyGraphDefs, AtUri} from '@atproto/api' -import {isInvalidHandle, isValidHandle} from '#/lib/strings/handles' +import {profileIdentifier} from '#/lib/strings/handles' import * as persisted from '#/state/persisted' export function makeProfileLink( @@ -11,13 +11,7 @@ export function makeProfileLink( ...segments: string[] ) { const useHandle = persisted.get('useHandleInLinks') ?? false - const identifier = - useHandle && - info.handle && - !isInvalidHandle(info.handle) && - isValidHandle(info.handle) - ? info.handle - : info.did + const identifier = useHandle ? profileIdentifier(info) : info.did return [`/profile`, identifier, ...segments].join('/') } diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index ebe049e41..d744837d3 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -29,6 +29,17 @@ export function isInvalidHandle(handle: string): boolean { return handle === 'handle.invalid' } +export function profileIdentifier(info: {did: string; handle: string}): string { + if ( + info.handle && + !isInvalidHandle(info.handle) && + isValidHandle(info.handle) + ) { + return info.handle + } + return info.did +} + export function sanitizeHandle( handle: string, prefix = '', diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 9c850155c..6d18f65f5 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -23,7 +23,7 @@ import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-p import {useSessionApi} from '#/state/session' import {getNativeOAuthClient} from '#/state/session/oauth-native-client' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, ios, useTheme} from '#/alf' +import {atoms as a, ios, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' @@ -34,6 +34,7 @@ import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticke import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {IS_IOS} from '#/env' +import {HandleAutocompleteInput} from './components/HandleAutocompleteInput' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -245,35 +246,20 @@ function OAuthLoginFields({ return ( <> - + Account - - - - { - identifierValueRef.current = v - }} - onSubmitEditing={onPressNext} - blurOnSubmit={false} - editable={!isProcessing} - accessibilityHint={_( - msg`Enter your handle (e.g. alice.bsky.social)`, - )} - /> - - + { + identifierValueRef.current = v + }} + onSubmit={onPressNext} + /> diff --git a/src/screens/Login/LoginForm.web.tsx b/src/screens/Login/LoginForm.web.tsx index ad3baabe1..c2d742cff 100644 --- a/src/screens/Login/LoginForm.web.tsx +++ b/src/screens/Login/LoginForm.web.tsx @@ -23,7 +23,7 @@ import {useSessionApi} from '#/state/session' import {getWebOAuthClient} from '#/state/session/oauth-web-client' import {saveOAuthReturnUrl} from '#/state/session/oauth-web-return-url' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' @@ -33,6 +33,7 @@ import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {HandleAutocompleteInput} from './components/HandleAutocompleteInput' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -230,35 +231,20 @@ function OAuthLoginFields({ return ( <> - + Account - - - - { - identifierValueRef.current = v - }} - onSubmitEditing={onPressNext} - blurOnSubmit={false} - editable={!isProcessing} - accessibilityHint={_( - msg`Enter your handle (e.g. alice.bsky.social)`, - )} - /> - - + { + identifierValueRef.current = v + }} + onSubmit={onPressNext} + /> diff --git a/src/screens/Login/components/HandleAutocompleteInput.tsx b/src/screens/Login/components/HandleAutocompleteInput.tsx new file mode 100644 index 000000000..d6365d6ff --- /dev/null +++ b/src/screens/Login/components/HandleAutocompleteInput.tsx @@ -0,0 +1,187 @@ +import {useRef, useState} from 'react' +import {Pressable, type TextInput, View} from 'react-native' +import {useSift} from '@bsky.app/sift' +import {useLingui} from '@lingui/react/macro' + +import {profileIdentifier} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, native, useTheme} from '#/alf' +import { + Autocomplete, + type AutocompleteItem, + useAutocomplete, +} from '#/components/Autocomplete' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import * as ProfileCard from '#/components/ProfileCard' +import {IS_NATIVE} from '#/env' + +export function HandleAutocompleteInput({ + initialValue = '', + onValueChange, + onSubmit, + editable = true, + autoFocus, + testID = 'loginUsernameInput', + label, + accessibilityHint, +}: { + initialValue?: string + onValueChange: (value: string) => void + onSubmit: () => void + editable?: boolean + autoFocus?: boolean + testID?: string + label: string + accessibilityHint?: string +}) { + const {t: l} = useLingui() + const t = useTheme() + const moderationOpts = useModerationOpts() + const [text, setText] = useState(initialValue) + const [active, setActive] = useState(false) + const blurTimeoutRef = useRef | null>(null) + const inputRef = useRef(null) + + const sift = useSift({ + offset: a.p_xs.padding, + placement: 'bottom-start', + dynamicWidth: true, + }) + + const trimmed = text.trim() + const showResults = active && trimmed.length > 0 + + const {items} = useAutocomplete({ + type: 'profile', + query: showResults ? text : '', + limit: 8, + }) + + const onChangeText = (value: string) => { + setText(value) + onValueChange(value) + if (!active && value.trim()) { + setActive(true) + } + } + + const onSelect = (item: AutocompleteItem) => { + if (item.type !== 'profile') return + + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current) + blurTimeoutRef.current = null + } + + const identifier = profileIdentifier(item.profile) + setText(identifier) + onValueChange(identifier) + setActive(false) + if (IS_NATIVE) { + inputRef.current?.blur() + } else { + sift.elements.input?.blur() + } + onSubmit() + } + + const onFocus = () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current) + blurTimeoutRef.current = null + } + if (trimmed.length > 0) { + setActive(true) + } + } + + const onBlur = () => { + blurTimeoutRef.current = setTimeout(() => { + setActive(false) + blurTimeoutRef.current = null + }, 150) + } + + const {ref: siftInputRef, ...siftA11yProps} = sift.targetProps + + return ( + + + + + + {showResults && items.length > 0 && IS_NATIVE && moderationOpts ? ( + + {items.map((item, index) => { + if (item.type !== 'profile') return null + const isLast = index === items.length - 1 + return ( + onSelect(item)} + style={({pressed}) => [ + a.py_sm, + a.px_md, + pressed && t.atoms.bg_contrast_25, + !isLast && [a.border_b, t.atoms.border_contrast_low], + ]}> + + + + + + ) + })} + + ) : showResults && items.length > 0 && !IS_NATIVE ? ( + setActive(false)} + /> + ) : null} + + ) +} -- 2.51.2