From bd0808a077432f07d7e3320e2a685de556175485 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Apr 2026 13:21:38 -0500 Subject: [PATCH] Integrate Sift into desktop search --- src/components/Autocomplete/Autocomplete.tsx | 23 +- .../Autocomplete/AutocompleteItemEmoji.tsx | 4 +- .../Autocomplete/AutocompleteItemProfile.tsx | 10 +- .../Autocomplete/AutocompleteItemSearch.tsx | 49 ++++ src/components/Autocomplete/index.tsx | 1 + src/components/Autocomplete/types.ts | 12 + .../index.ts} | 34 ++- src/components/Composer/index.tsx | 12 +- .../Search/components/AutocompleteResults.tsx | 62 ++++- src/view/shell/desktop/Search.tsx | 232 +++++++----------- 10 files changed, 271 insertions(+), 168 deletions(-) create mode 100644 src/components/Autocomplete/AutocompleteItemSearch.tsx rename src/components/Autocomplete/{useAutocomplete.ts => useAutocomplete/index.ts} (85%) diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index 1a4e441369..085fc47910 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,4 +1,5 @@ import {useCallback} from 'react' +import {View} from 'react-native' import {Sift, type UseSiftReturn} from '@bsky.app/sift' import {atoms as a, useTheme} from '#/alf' @@ -6,17 +7,35 @@ import {type AutocompleteItem} from '#/components/Autocomplete/types' import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' import {Portal} from '#/components/Portal' import {IS_WEB} from '#/env' +import {AutocompleteItemEmoji} from './AutocompleteItemEmoji' +import {AutocompleteItemProfile} from './AutocompleteItemProfile' +import {AutocompleteItemSearch} from './AutocompleteItemSearch' + +function renderItem( + item: Parameters>[0]['render']>[0], +) { + switch (item.item.type) { + case 'profile': + return + case 'emoji': + return + case 'search': + return + default: + return + } +} export function Autocomplete({ sift, data, - render, + render = renderItem, onSelect, onDismiss, }: { sift: UseSiftReturn data: AutocompleteItem[] - render: Parameters>[0]['render'] + render?: Parameters>[0]['render'] onSelect: (item: AutocompleteItem) => void onDismiss: () => void }) { diff --git a/src/components/Autocomplete/AutocompleteItemEmoji.tsx b/src/components/Autocomplete/AutocompleteItemEmoji.tsx index 7fd2f82622..91d963f94e 100644 --- a/src/components/Autocomplete/AutocompleteItemEmoji.tsx +++ b/src/components/Autocomplete/AutocompleteItemEmoji.tsx @@ -1,6 +1,5 @@ import {SiftItem} from '@bsky.app/sift' -import {useModerationOpts} from '#/state/preferences/moderation-opts' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {type AutocompleteItemProps} from './types' @@ -11,9 +10,8 @@ export function AutocompleteItemEmoji({ item, }: AutocompleteItemProps) { const t = useTheme() - const moderationOpts = useModerationOpts() - if (item.type !== 'emoji' || !moderationOpts) return null + if (item.type !== 'emoji') return null return ( [ - a.px_md, a.py_sm, + a.px_md, active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], + isFirst && { + paddingTop: a.py_sm.paddingTop * 1.2, + }, + isLast && { + paddingBottom: a.py_sm.paddingTop * 1.2, + }, ]}> [ + a.py_sm, + a.px_md, + a.flex_row, + a.align_center, + a.gap_sm, + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], + isFirst && { + paddingTop: a.py_sm.paddingTop * 1.2, + }, + isLast && { + paddingBottom: a.py_sm.paddingTop * 1.2, + }, + ]}> + + + + {item.value} + + ) +} diff --git a/src/components/Autocomplete/index.tsx b/src/components/Autocomplete/index.tsx index 4dabf25fae..b53fcb81f3 100644 --- a/src/components/Autocomplete/index.tsx +++ b/src/components/Autocomplete/index.tsx @@ -1,5 +1,6 @@ export * from './Autocomplete' export * from './AutocompleteItemEmoji' export * from './AutocompleteItemProfile' +export * from './types' export * from './useAutocomplete' export * from './util' diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts index 1bf31bad76..fef38ab796 100644 --- a/src/components/Autocomplete/types.ts +++ b/src/components/Autocomplete/types.ts @@ -24,13 +24,25 @@ export type AutocompleteEmoji = { emoji: Emoji } +export type AutocompleteSearch = { + key: string + type: 'search' + value: string +} + export type AutocompleteItem = | AutocompleteProfile | AutocompleteTag | AutocompleteEmoji + | AutocompleteSearch export type AutocompleteItemType = AutocompleteItem['type'] export type AutocompleteItemProps = Parameters< Parameters>[0]['render'] >[0] + +export type AutocompleteApi = { + query: string + items: AutocompleteItem[] +} diff --git a/src/components/Autocomplete/useAutocomplete.ts b/src/components/Autocomplete/useAutocomplete/index.ts similarity index 85% rename from src/components/Autocomplete/useAutocomplete.ts rename to src/components/Autocomplete/useAutocomplete/index.ts index 20ce2aa808..560c77826f 100644 --- a/src/components/Autocomplete/useAutocomplete.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -11,6 +11,7 @@ import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' import {useAgent} from '#/state/session' import { + type AutocompleteApi, type AutocompleteItem, type AutocompleteItemType, type AutocompleteProfile, @@ -47,24 +48,26 @@ function useEmojiSearch() { export function useAutocomplete({ type, - query, + query: q, limit, + showSearchFallback = false, }: { type: AutocompleteItemType query: string limit?: number -}) { + showSearchFallback?: boolean +}): AutocompleteApi { const agent = useAgent() const moderationOpts = useModerationOpts() const emojiSearch = useEmojiSearch() - return useQuery({ + const query = useQuery({ staleTime: STALE.MINUTES.ONE, queryKey: [ 'autocomplete', { type, - query, + query: q, }, ], async queryFn() { @@ -73,10 +76,10 @@ export function useAutocomplete({ if (!query) return [] // Going from "foo" to "foo." should not clear matches. - query = query.toLowerCase().trim().replace(/\.$/, '') + q = q.toLowerCase().trim().replace(/\.$/, '') const res = await agent.searchActorsTypeahead({ - q: query, + q, limit: limit || 8, }) @@ -87,7 +90,7 @@ export function useAutocomplete({ profile, })) } else if (type === 'emoji') { - const results = await emojiSearch(query, limit || 8) + const results = await emojiSearch(q, limit || 8) return results.map(result => ({ key: result.item.id, type: 'emoji' as const, @@ -109,7 +112,7 @@ export function useAutocomplete({ if (item.type === 'profile') { const moderated = moderateProfileItem({ - query, + query: q, item, moderationOpts: moderationOpts || DEFAULT_MOD_OPTS, }) @@ -119,12 +122,25 @@ export function useAutocomplete({ } } + if (showSearchFallback && q) { + results.unshift({ + key: `search-${q}`, + type: 'search' as const, + value: q, + }) + } + return results }, - [query, moderationOpts], + [q, showSearchFallback, moderationOpts], ), placeholderData: keepPreviousData, }) + + return { + query: q, + items: query.data || [], + } } function moderateProfileItem({ diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index cffdc6cdce..1069c5b932 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -403,7 +403,7 @@ function AutocompleteInner({ activeFacet: TapperActiveFacet onDismiss: () => void }) { - const {data} = useAutocomplete({ + const {items} = useAutocomplete({ type: parseAutocompleteItemType(activeFacet.type), query: activeFacet.value, }) @@ -417,17 +417,17 @@ function AutocompleteInner({ useEffect(() => { if (activeFacet?.type === 'emoji' && activeFacet.raw.endsWith(':')) { - if (data?.[0]) { - activeFacet.replace(data[0].value, {noTrailingSpace: true}) + if (items?.[0]) { + activeFacet.replace(items[0].value, {noTrailingSpace: true}) onDismiss() } } - }, [data, activeFacet]) + }, [items, activeFacet]) - return data && data.length ? ( + return items && items.length ? ( { if (props.item.type === 'profile') { return diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx index c36c2c15ef..08aca95638 100644 --- a/src/screens/Search/components/AutocompleteResults.tsx +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -1,11 +1,18 @@ import {memo} from 'react' -import {ActivityIndicator, View} from 'react-native' +import { + ActivityIndicator, + TouchableOpacity, + View, + type ViewStyle, +} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {usePalette} from '#/lib/hooks/usePalette' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {SearchLinkCard} from '#/view/shell/desktop/Search' +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, native} from '#/alf' import * as Layout from '#/components/Layout' @@ -76,3 +83,54 @@ let AutocompleteResults = ({ } AutocompleteResults = memo(AutocompleteResults) export {AutocompleteResults} + +let SearchLinkCard = ({ + label, + to, + onPress, + style, +}: { + label: string + to?: string + onPress?: () => void + style?: ViewStyle +}): React.ReactNode => { + const pal = usePalette('default') + + const inner = ( + + + {label} + + + ) + + if (onPress) { + return ( + + {inner} + + ) + } + + return ( + + + + {label} + + + + ) +} +SearchLinkCard = memo(SearchLinkCard) +export {SearchLinkCard} diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index ede77640dc..f6268d1358 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,166 +1,108 @@ -import {memo, useCallback, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, - type ViewStyle, -} from 'react-native' -import {useLingui} from '@lingui/react/macro' +import {useState} from 'react' +import {View} from 'react-native' +import {useSift} from '@bsky.app/sift' import {StackActions, useNavigation} from '@react-navigation/native' -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 { + Autocomplete as AutocompleteBase, + type AutocompleteItem, + useAutocomplete, +} from '#/components/Autocomplete' import {SearchInput} from '#/components/forms/SearchInput' -let SearchLinkCard = ({ - label, - to, - onPress, - style, -}: { - label: string - to?: string - onPress?: () => void - style?: ViewStyle -}): React.ReactNode => { - const pal = usePalette('default') +export function DesktopSearch() { + const navigation = useNavigation() + const [active, setActive] = useState(false) + const [query, setQuery] = useState('') + const showResults = active && !!query.length - const inner = ( - - - {label} - - - ) + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'bottom', + }) - if (onPress) { - return ( - - {inner} - - ) + const onFocus = () => { + if (query.length) setActive(true) + } + + const onChangeText = (text: string) => { + setQuery(text) + if (!active) { + setActive(true) + } + } + + const onClearText = () => { + setQuery('') + setActive(false) + } + + const onSubmit = () => { + if (!query.length) return + onClearText() + sift.elements.input.blur() + navigation.dispatch(StackActions.push('Search', {q: query})) + } + + const onSelect = (item: AutocompleteItem) => { + if (item.type === 'profile') { + onClearText() + sift.elements.input.blur() + navigation.navigate('Profile', {name: item.profile.handle}) + } else if (item.type === 'search') { + onClearText() + sift.elements.input.blur() + navigation.navigate('Search', {q: item.value}) + } } return ( - - - - {label} - - - - ) -} -SearchLinkCard = memo(SearchLinkCard) -export {SearchLinkCard} - -export function DesktopSearch() { - const {t: l} = useLingui() - const pal = usePalette('default') - const navigation = useNavigation() - const [isActive, setIsActive] = useState(false) - const [query, setQuery] = useState('') - const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( - query, - true, - ) - - const moderationOpts = useModerationOpts() - - const onChangeText = useCallback((text: string) => { - setQuery(text) - setIsActive(text.length > 0) - }, []) - - const onPressCancelSearch = useCallback(() => { - setQuery('') - setIsActive(false) - }, [setQuery]) - - const onSubmit = useCallback(() => { - setIsActive(false) - if (!query.length) return - navigation.dispatch(StackActions.push('Search', {q: query})) - }, [query, navigation]) - - const onSearchProfileCardPress = useCallback(() => { - setQuery('') - setIsActive(false) - }, []) - - return ( - + - {query !== '' && isActive && moderationOpts && ( - - {isFetching && !autocompleteData?.length ? ( - - - - ) : ( - <> - 0 - ? {borderBottomWidth: 1} - : undefined - } - /> - {autocompleteData?.map(item => ( - - ))} - - )} - + {showResults && ( + setActive(false)} + /> )} ) } -const styles = StyleSheet.create({ - container: { - position: 'relative', - width: '100%', - }, - resultsContainer: { - marginTop: 10, - flexDirection: 'column', - width: '100%', - borderWidth: 1, - borderRadius: 6, - }, -}) +function Inner({ + query, + sift, + onSelect, + onDismiss, +}: { + query: string + sift: ReturnType + onSelect: (item: AutocompleteItem) => void + onDismiss: () => void +}) { + const {items} = useAutocomplete({ + type: 'profile', + query, + showSearchFallback: true, + }) + + return items && items.length ? ( + + ) : null +}