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-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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue