Compare commits

...

6 Commits

Author SHA1 Message Date
Samuel Newman a06b58e831 try using downshift 2025-08-06 21:05:38 +03:00
Samuel Newman 48c53603d8 rm forwardRef 2025-08-06 17:13:10 +03:00
Samuel Newman 0bd1053798 fix type error, extract and plat-split a component 2025-08-06 17:12:02 +03:00
Samuel Newman ce1f5a1f58 let's just ALF the hell out of it 2025-08-06 17:12:02 +03:00
Samuel Newman 7bafcf1cc2 defer and ALF sidebar search 2025-08-06 17:12:02 +03:00
Samuel Newman 290b52fb04 defer search screen 2025-08-06 17:12:02 +03:00
8 changed files with 323 additions and 202 deletions
+1
View File
@@ -127,6 +127,7 @@
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"deprecated-react-native-prop-types": "^5.0.0",
"downshift": "^9.0.10",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
+85 -62
View File
@@ -1,5 +1,9 @@
import React from 'react'
import {TextInput, View} from 'react-native'
import {
type NativeSyntheticEvent,
type TextInput,
type TextInputKeyPressEventData,
View,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,72 +16,91 @@ import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
ref?: React.Ref<TextInput>
label?: TextField.InputProps['label']
/**
* Called when the user presses the (X) button
*/
onClearText?: () => void
/**
* Called when the user presses the Escape key
*/
onEscape?: () => void
}
export const SearchInput = React.forwardRef<TextInput, SearchInputProps>(
function SearchInput({value, label, onClearText, ...rest}, ref) {
const t = useTheme()
const {_} = useLingui()
const showClear = value && value.length > 0
export function SearchInput({
ref,
value,
label,
onClearText,
onEscape,
...rest
}: SearchInputProps) {
const t = useTheme()
const {_} = useLingui()
const showClear = value && value.length > 0
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={ref}
label={label || _(msg`Search`)}
value={value}
placeholder={_(msg`Search`)}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={isNative}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
style={[
showClear
? {
paddingRight: 24,
}
: {},
]}
{...rest}
/>
</TextField.Root>
const onKeyPress = (
evt: NativeSyntheticEvent<TextInputKeyPressEventData>,
) => {
if (evt.nativeEvent.key === 'Escape') {
onEscape?.()
}
}
{showClear && (
<View
style={[
a.absolute,
a.z_20,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={_(msg`Clear search query`)}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
},
)
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={ref}
label={label || _(msg`Search`)}
value={value}
placeholder={_(msg`Search`)}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={isNative}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
onKeyPress={onKeyPress}
style={[
showClear
? {
paddingRight: 24,
}
: {},
]}
{...rest}
/>
</TextField.Root>
{showClear && (
<View
style={[
a.absolute,
a.z_20,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={_(msg`Clear search query`)}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
}
+8 -7
View File
@@ -1,6 +1,7 @@
import {
memo,
useCallback,
useDeferredValue,
useLayoutEffect,
useMemo,
useRef,
@@ -75,9 +76,10 @@ export function SearchScreenShell({
const queryClient = useQueryClient()
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, setSearchText] = useState(queryParam)
const deferredSearchText = useDeferredValue(searchText)
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
useActorAutocompleteQuery(searchText, true)
useActorAutocompleteQuery(deferredSearchText, true)
const [showAutocomplete, setShowAutocomplete] = useState(false)
@@ -203,8 +205,8 @@ export function SearchScreenShell({
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
const onSubmit = useCallback(() => {
navigateToItem(searchText)
}, [navigateToItem, searchText])
navigateToItem(deferredSearchText)
}, [navigateToItem, deferredSearchText])
const onAutocompleteResultPress = useCallback(() => {
if (isWeb) {
@@ -376,11 +378,11 @@ export function SearchScreenShell({
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
flex: 1,
}}>
{searchText.length > 0 ? (
{deferredSearchText.length > 0 ? (
<AutocompleteResults
isAutocompleteFetching={isAutocompleteFetching}
autocompleteData={autocompleteData}
searchText={searchText}
searchText={deferredSearchText}
onSubmit={onSubmit}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
@@ -428,7 +430,6 @@ let SearchScreenInner = ({
const {hasSession} = useSession()
const {gtTablet} = useBreakpoints()
const [activeTab, setActiveTab] = useState(0)
const {_} = useLingui()
const onPageSelected = useCallback(
(index: number) => {
@@ -4,12 +4,11 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {SearchLinkCard} from '#/view/shell/desktop/Search'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a, native} from '#/alf'
import {atoms as a} from '#/alf'
import * as Layout from '#/components/Layout'
import {SearchLinkCard} from './SearchLinkCard'
let AutocompleteResults = ({
isAutocompleteFetching,
@@ -43,12 +42,8 @@ let AutocompleteResults = ({
keyboardDismissMode="on-drag">
<SearchLinkCard
label={_(msg`Search for "${searchText}"`)}
onPress={native(onSubmit)}
to={
isNative
? undefined
: `/search?q=${encodeURIComponent(searchText)}`
}
onPress={onSubmit}
to={{screen: 'Search', params: {q: searchText}}}
style={a.border_b}
/>
{autocompleteData?.map(item => (
@@ -0,0 +1,42 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
export function SearchLinkCard({
label,
onPress,
style,
}: {
label: string
/**
* @platform web
*/
to: LinkProps['to']
/**
* @platform native
*/
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button label={label} onPress={onPress}>
{({focused, hovered, pressed}) => (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.p_lg,
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
style,
]}>
<Text style={[a.text_sm, a.leading_snug]}>{label}</Text>
</View>
)}
</Button>
)
}
@@ -0,0 +1,42 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Link, type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
export function SearchLinkCard({
label,
to,
style,
}: {
label: string
/**
* @platform web
*/
to: LinkProps['to']
/**
* @platform native
*/
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Link to={to} label={label}>
{({focused, hovered, pressed}) => (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.py_lg,
a.px_md,
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
style,
]}>
<Text style={[a.text_sm, a.leading_snug]}>{label}</Text>
</View>
)}
</Link>
)
}
+114 -118
View File
@@ -1,167 +1,163 @@
import React from 'react'
import {useDeferredValue, useMemo, useRef, useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
type StyleProp,
type TextInput,
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useCombobox} from 'downshift'
import {usePalette} from '#/lib/hooks/usePalette'
import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {Link} from '#/view/com/util/Link'
import {Text} from '#/view/com/util/text/Text'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {Link, type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
let SearchLinkCard = ({
function SearchLinkCard({
label,
to,
onPress,
style,
}: {
label: string
to?: string
onPress?: () => void
style?: ViewStyle
}): React.ReactNode => {
const pal = usePalette('default')
const inner = (
<View
style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}>
<Text type="md" style={[pal.text]}>
{label}
</Text>
</View>
)
if (onPress) {
return (
<TouchableOpacity
onPress={onPress}
accessibilityLabel={label}
accessibilityHint="">
{inner}
</TouchableOpacity>
)
}
to: LinkProps['to']
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Link href={to} asAnchor anchorNoUnderline>
<View
style={[
pal.border,
{paddingVertical: 16, paddingHorizontal: 12},
style,
]}>
<Text type="md" style={[pal.text]}>
{label}
</Text>
</View>
<Link to={to} label={label}>
{({focused, hovered, pressed}) => (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.py_lg,
a.px_md,
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
style,
]}>
<Text style={[a.text_sm, a.leading_snug]}>{label}</Text>
</View>
)}
</Link>
)
}
SearchLinkCard = React.memo(SearchLinkCard)
export {SearchLinkCard}
export function DesktopSearch() {
const {_} = useLingui()
const pal = usePalette('default')
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const [isActive, setIsActive] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const searchInputRef = useRef<TextInput>(null)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
query,
deferredQuery,
true,
)
const items = useMemo(() => {
if (deferredQuery.length === 0) return []
return ['__TEXT__', ...(autocompleteData || [])]
}, [deferredQuery, autocompleteData])
const {getInputProps, isOpen} = useCombobox({
items,
getItemId: index =>
typeof items[index] === 'string' ? items[index] : items[index].did,
})
const moderationOpts = useModerationOpts()
const onChangeText = React.useCallback((text: string) => {
setQuery(text)
setIsActive(text.length > 0)
}, [])
const onPressCancelSearch = React.useCallback(() => {
const onPressCancelSearch = () => {
setQuery('')
setIsActive(false)
}, [setQuery])
}
const onSubmit = React.useCallback(() => {
setIsActive(false)
if (!query.length) return
navigation.dispatch(StackActions.push('Search', {q: query}))
}, [query, navigation])
const onSearchProfileCardPress = React.useCallback(() => {
const onEscape = () => {
setQuery('')
setIsActive(false)
}, [])
searchInputRef.current?.blur()
}
const onSubmit = () => {
if (!deferredQuery.length) return
navigation.dispatch(StackActions.push('Search', {q: deferredQuery}))
setQuery('')
}
const onSearchProfileCardPress = () => {
setQuery('')
}
console.log(getInputProps())
return (
<View style={[styles.container, pal.view]}>
<View style={[a.w_full, a.z_10]}>
<SearchInput
value={query}
onChangeText={onChangeText}
ref={searchInputRef}
onClearText={onPressCancelSearch}
onEscape={onEscape}
onSubmitEditing={onSubmit}
{...getInputProps()}
/>
{query !== '' && isActive && moderationOpts && (
<View
style={[
pal.view,
pal.borderDark,
styles.resultsContainer,
a.overflow_hidden,
]}>
{isFetching && !autocompleteData?.length ? (
<View style={{padding: 8}}>
<ActivityIndicator />
</View>
) : (
<>
<SearchLinkCard
label={_(msg`Search for "${query}"`)}
to={`/search?q=${encodeURIComponent(query)}`}
style={
(autocompleteData?.length ?? 0) > 0
? {borderBottomWidth: 1}
: undefined
}
/>
{autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderationOpts={moderationOpts}
onPress={onSearchProfileCardPress}
{isOpen && moderationOpts && (
<View style={[a.w_full]}>
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.w_full,
a.border,
a.mt_sm,
a.rounded_sm,
a.overflow_hidden,
a.absolute,
a.shadow_lg,
a.zoom_fade_in,
]}>
{deferredQuery.length === 0 ? (
<View style={[a.py_xl, a.gap_sm, a.align_center]}>
<SearchIcon size="2xl" style={[t.atoms.text_contrast_low]} />
<Text
style={[a.text_sm, t.atoms.text_contrast_low, a.text_center]}>
<Trans>Start typing to search</Trans>
</Text>
</View>
) : (
<>
<SearchLinkCard
label={_(msg`Search for "${deferredQuery}"`)}
to={{screen: 'Search', params: {q: deferredQuery}}}
style={[
((autocompleteData?.length ?? 0) > 0 || isFetching) &&
a.border_b,
]}
/>
))}
</>
)}
{isFetching && !autocompleteData?.length ? (
<View style={[a.p_md]}>
<ActivityIndicator />
</View>
) : (
autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderationOpts={moderationOpts}
onPress={onSearchProfileCardPress}
/>
))
)}
</>
)}
</View>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'relative',
width: '100%',
},
resultsContainer: {
marginTop: 10,
flexDirection: 'column',
width: '100%',
borderWidth: 1,
borderRadius: 6,
},
})
+27 -6
View File
@@ -3252,6 +3252,11 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.24.5":
version "7.28.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473"
integrity sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==
"@babel/runtime@^7.25.0", "@babel/runtime@^7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1"
@@ -9523,6 +9528,11 @@ compression@^1.7.4:
safe-buffer "5.1.2"
vary "~1.1.2"
compute-scroll-into-view@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz#02c3386ec531fb6a9881967388e53e8564f3e9aa"
integrity sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -10334,6 +10344,17 @@ dotenv@^16.4.4, dotenv@^16.4.5, dotenv@~16.4.5:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
downshift@^9.0.10:
version "9.0.10"
resolved "https://registry.yarnpkg.com/downshift/-/downshift-9.0.10.tgz#44e4d34bba63c1b7cd5a41ea4bead1b569ae061c"
integrity sha512-TP/iqV6bBok6eGD5tZ8boM8Xt7/+DZvnVNr8cNIhbAm2oUBd79Tudiccs2hbcV9p7xAgS/ozE7Hxy3a9QqS6Mw==
dependencies:
"@babel/runtime" "^7.24.5"
compute-scroll-into-view "^3.1.0"
prop-types "^15.8.1"
react-is "18.2.0"
tslib "^2.6.2"
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -16757,6 +16778,11 @@ react-image-crop@^11.0.7:
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e"
integrity sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==
react-is@18.2.0, react-is@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-is@19, react-is@^19.0.0, react-is@^19.1.0:
version "19.1.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.1.0.tgz#805bce321546b7e14c084989c77022351bbdd11b"
@@ -16767,11 +16793,6 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-keyed-flatten-children@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/react-keyed-flatten-children/-/react-keyed-flatten-children-5.0.0.tgz#3024fc8819f7b60fc5039b527f133d9ac3a02a82"
@@ -19004,7 +19025,7 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@2:
tslib@2, tslib@^2.6.2:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==