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 moderationzio/stable
parent
ab1ce078ec
commit
d1cb74febe
|
@ -106,6 +106,7 @@
|
|||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^7.0.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import React from 'react'
|
||||
import {AppBskyActorDefs, BskyAgent} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useSession} from '../session'
|
||||
import {useMyFollowsQuery} from './my-follows'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
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]
|
||||
|
||||
|
@ -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 {
|
||||
// state
|
||||
isLoading = false
|
||||
|
|
|
@ -1,59 +1,136 @@
|
|||
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 {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ProfileModeration,
|
||||
} from '@atproto/api'
|
||||
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 {useLingui} from '@lingui/react'
|
||||
|
||||
export const DesktopSearch = observer(function DesktopSearch() {
|
||||
const store = useStores()
|
||||
import {s} from '#/lib/styles'
|
||||
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')
|
||||
|
||||
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 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 [query, setQuery] = React.useState<string>('')
|
||||
const autocompleteView = React.useMemo<UserAutocompleteModel>(
|
||||
() => new UserAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
AppBskyActorDefs.ProfileViewBasic[]
|
||||
>([])
|
||||
|
||||
// initial setup
|
||||
React.useEffect(() => {
|
||||
if (store.me.did) {
|
||||
autocompleteView.setup()
|
||||
}
|
||||
}, [autocompleteView, store.me.did])
|
||||
const moderationOpts = useModerationOpts()
|
||||
const search = useActorSearch()
|
||||
|
||||
const onChangeQuery = React.useCallback(
|
||||
(text: string) => {
|
||||
const onChangeText = React.useCallback(
|
||||
async (text: string) => {
|
||||
setQuery(text)
|
||||
|
||||
if (text.length > 0 && isInputFocused) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(text)
|
||||
if (searchDebounceTimeout.current)
|
||||
clearTimeout(searchDebounceTimeout.current)
|
||||
|
||||
searchDebounceTimeout.current = setTimeout(async () => {
|
||||
const results = await search({query: text})
|
||||
|
||||
if (results) {
|
||||
setSearchResults(results)
|
||||
}
|
||||
}, 300)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
if (searchDebounceTimeout.current)
|
||||
clearTimeout(searchDebounceTimeout.current)
|
||||
setSearchResults([])
|
||||
}
|
||||
},
|
||||
[setQuery, autocompleteView, isInputFocused],
|
||||
[setQuery, isInputFocused, search, setSearchResults],
|
||||
)
|
||||
|
||||
const onPressCancelSearch = React.useCallback(() => {
|
||||
setQuery('')
|
||||
autocompleteView.setActive(false)
|
||||
}, [setQuery, autocompleteView])
|
||||
onChangeText('')
|
||||
}, [onChangeText])
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
navigation.dispatch(StackActions.push('Search', {q: query}))
|
||||
autocompleteView.setActive(false)
|
||||
}, [query, navigation, autocompleteView])
|
||||
}, [query, navigation])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
|
@ -66,7 +143,6 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||
/>
|
||||
<TextInput
|
||||
testID="searchTextInput"
|
||||
ref={textInput}
|
||||
placeholder="Search"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
selectTextOnFocus
|
||||
|
@ -75,7 +151,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||
style={[pal.textLight, styles.input]}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onChangeText={onChangeQuery}
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmit}
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
|
@ -100,16 +176,19 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||
|
||||
{query !== '' && (
|
||||
<View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
|
||||
{autocompleteView.suggestions.length ? (
|
||||
<>
|
||||
{autocompleteView.suggestions.map((item, i) => (
|
||||
<ProfileCard key={item.did} profile={item} noBorder={i === 0} />
|
||||
))}
|
||||
</>
|
||||
{searchResults.length && moderationOpts ? (
|
||||
searchResults.map((item, i) => (
|
||||
<SearchResultCard
|
||||
key={item.did}
|
||||
profile={item}
|
||||
moderation={moderateProfile(item, moderationOpts)}
|
||||
style={i === 0 ? {borderTopWidth: 0} : {}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View>
|
||||
<Text style={[pal.textLight, styles.noResults]}>
|
||||
<Trans>No results found for {autocompleteView.prefix}</Trans>
|
||||
<Trans>No results found for {query}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
@ -153,15 +232,11 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 7,
|
||||
},
|
||||
resultsContainer: {
|
||||
// @ts-ignore supported by web
|
||||
// position: 'fixed',
|
||||
marginTop: 10,
|
||||
|
||||
flexDirection: 'column',
|
||||
width: 300,
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
noResults: {
|
||||
textAlign: 'center',
|
||||
|
|
|
@ -10560,6 +10560,11 @@ funpermaproxy@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
|
||||
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:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
|
|
Loading…
Reference in New Issue