Desktop user autocomplete search (#1906)

* Fix notification provider order, add comments

* Remove log

* Add actor typeahead handling

* Trim down desktop search styles and hooks

* Clean up moderation
zio/stable
Eric Bailey 2023-11-14 19:51:23 -06:00 committed by GitHub
parent ab1ce078ec
commit d1cb74febe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 186 additions and 49 deletions

View File

@ -106,6 +106,7 @@
"expo-system-ui": "~2.4.0", "expo-system-ui": "~2.4.0",
"expo-updates": "~0.18.12", "expo-updates": "~0.18.12",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"fuse.js": "^7.0.0",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"lande": "^1.0.10", "lande": "^1.0.10",

View File

@ -1,8 +1,12 @@
import React from 'react'
import {AppBskyActorDefs, BskyAgent} from '@atproto/api' import {AppBskyActorDefs, BskyAgent} from '@atproto/api'
import {useQuery} from '@tanstack/react-query' import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useSession} from '../session'
import {useMyFollowsQuery} from './my-follows'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import Fuse from 'fuse.js'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {useMyFollowsQuery} from '#/state/queries/my-follows'
export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
@ -22,6 +26,58 @@ export function useActorAutocompleteQuery(prefix: string) {
}) })
} }
export function useActorSearch() {
const queryClient = useQueryClient()
const {agent} = useSession()
const {data: follows} = useMyFollowsQuery()
const followsSearch = React.useMemo(() => {
if (!follows) return undefined
return new Fuse(follows, {
includeScore: true,
keys: ['displayName', 'handle'],
})
}, [follows])
return React.useCallback(
async ({query}: {query: string}) => {
let searchResults: AppBskyActorDefs.ProfileViewBasic[] = []
if (followsSearch) {
const results = followsSearch.search(query)
searchResults = results.map(({item}) => item)
}
try {
const res = await queryClient.fetchQuery({
// cached for 1 min
staleTime: 60 * 1000,
queryKey: ['search', query],
queryFn: () =>
agent.searchActorsTypeahead({
term: query,
limit: 8,
}),
})
if (res.data.actors) {
for (const actor of res.data.actors) {
if (!searchResults.find(item => item.handle === actor.handle)) {
searchResults.push(actor)
}
}
}
} catch (e) {
logger.error('useActorSearch: searchActorsTypeahead failed', {error: e})
}
return searchResults
},
[agent, followsSearch, queryClient],
)
}
export class ActorAutocomplete { export class ActorAutocomplete {
// state // state
isLoading = false isLoading = false

View File

@ -1,59 +1,136 @@
import React from 'react' import React from 'react'
import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' import {
ViewStyle,
TextInput,
View,
StyleSheet,
TouchableOpacity,
} from 'react-native'
import {useNavigation, StackActions} from '@react-navigation/native' import {useNavigation, StackActions} from '@react-navigation/native'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {
AppBskyActorDefs,
moderateProfile,
ProfileModeration,
} from '@atproto/api'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon2} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {Text} from 'view/com/util/text/Text'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
export const DesktopSearch = observer(function DesktopSearch() { import {s} from '#/lib/styles'
const store = useStores() import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {makeProfileLink} from '#/lib/routes/links'
import {Link} from '#/view/com/util/Link'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon2} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useActorSearch} from '#/state/queries/actor-autocomplete'
import {useModerationOpts} from '#/state/queries/preferences'
export function SearchResultCard({
profile,
style,
moderation,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
style: ViewStyle
moderation: ProfileModeration
}) {
const pal = usePalette('default') const pal = usePalette('default')
return (
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View
style={[
pal.border,
style,
{
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 8,
paddingHorizontal: 12,
},
]}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
<View style={{flex: 1}}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
</Link>
)
}
export const DesktopSearch = observer(function DesktopSearch() {
const {_} = useLingui() const {_} = useLingui()
const textInput = React.useRef<TextInput>(null) const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
undefined,
)
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('') const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteModel>( const [searchResults, setSearchResults] = React.useState<
() => new UserAutocompleteModel(store), AppBskyActorDefs.ProfileViewBasic[]
[store], >([])
)
const navigation = useNavigation<NavigationProp>()
// initial setup const moderationOpts = useModerationOpts()
React.useEffect(() => { const search = useActorSearch()
if (store.me.did) {
autocompleteView.setup()
}
}, [autocompleteView, store.me.did])
const onChangeQuery = React.useCallback( const onChangeText = React.useCallback(
(text: string) => { async (text: string) => {
setQuery(text) setQuery(text)
if (text.length > 0 && isInputFocused) { if (text.length > 0 && isInputFocused) {
autocompleteView.setActive(true) if (searchDebounceTimeout.current)
autocompleteView.setPrefix(text) clearTimeout(searchDebounceTimeout.current)
searchDebounceTimeout.current = setTimeout(async () => {
const results = await search({query: text})
if (results) {
setSearchResults(results)
}
}, 300)
} else { } else {
autocompleteView.setActive(false) if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
setSearchResults([])
} }
}, },
[setQuery, autocompleteView, isInputFocused], [setQuery, isInputFocused, search, setSearchResults],
) )
const onPressCancelSearch = React.useCallback(() => { const onPressCancelSearch = React.useCallback(() => {
setQuery('') onChangeText('')
autocompleteView.setActive(false) }, [onChangeText])
}, [setQuery, autocompleteView])
const onSubmit = React.useCallback(() => { const onSubmit = React.useCallback(() => {
navigation.dispatch(StackActions.push('Search', {q: query})) navigation.dispatch(StackActions.push('Search', {q: query}))
autocompleteView.setActive(false) }, [query, navigation])
}, [query, navigation, autocompleteView])
return ( return (
<View style={[styles.container, pal.view]}> <View style={[styles.container, pal.view]}>
@ -66,7 +143,6 @@ export const DesktopSearch = observer(function DesktopSearch() {
/> />
<TextInput <TextInput
testID="searchTextInput" testID="searchTextInput"
ref={textInput}
placeholder="Search" placeholder="Search"
placeholderTextColor={pal.colors.textLight} placeholderTextColor={pal.colors.textLight}
selectTextOnFocus selectTextOnFocus
@ -75,7 +151,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
style={[pal.textLight, styles.input]} style={[pal.textLight, styles.input]}
onFocus={() => setIsInputFocused(true)} onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery} onChangeText={onChangeText}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
accessibilityRole="search" accessibilityRole="search"
accessibilityLabel={_(msg`Search`)} accessibilityLabel={_(msg`Search`)}
@ -100,16 +176,19 @@ export const DesktopSearch = observer(function DesktopSearch() {
{query !== '' && ( {query !== '' && (
<View style={[pal.view, pal.borderDark, styles.resultsContainer]}> <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
{autocompleteView.suggestions.length ? ( {searchResults.length && moderationOpts ? (
<> searchResults.map((item, i) => (
{autocompleteView.suggestions.map((item, i) => ( <SearchResultCard
<ProfileCard key={item.did} profile={item} noBorder={i === 0} /> key={item.did}
))} profile={item}
</> moderation={moderateProfile(item, moderationOpts)}
style={i === 0 ? {borderTopWidth: 0} : {}}
/>
))
) : ( ) : (
<View> <View>
<Text style={[pal.textLight, styles.noResults]}> <Text style={[pal.textLight, styles.noResults]}>
<Trans>No results found for {autocompleteView.prefix}</Trans> <Trans>No results found for {query}</Trans>
</Text> </Text>
</View> </View>
)} )}
@ -153,15 +232,11 @@ const styles = StyleSheet.create({
paddingVertical: 7, paddingVertical: 7,
}, },
resultsContainer: { resultsContainer: {
// @ts-ignore supported by web
// position: 'fixed',
marginTop: 10, marginTop: 10,
flexDirection: 'column', flexDirection: 'column',
width: 300, width: 300,
borderWidth: 1, borderWidth: 1,
borderRadius: 6, borderRadius: 6,
paddingVertical: 4,
}, },
noResults: { noResults: {
textAlign: 'center', textAlign: 'center',

View File

@ -10560,6 +10560,11 @@ funpermaproxy@^1.1.0:
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557" resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ== integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==
fuse.js@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
gensync@^1.0.0-beta.2: gensync@^1.0.0-beta.2:
version "1.0.0-beta.2" version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"