diff --git a/package.json b/package.json index 88d0c15ecd..61a06983a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 62c4781c42..1bfa13f813 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -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 diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index f54858b8a1..115e0f7ae3 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -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 {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +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 {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' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {useActorSearch} from '#/state/queries/actor-autocomplete' +import {useModerationOpts} from '#/state/queries/preferences' -export const DesktopSearch = observer(function DesktopSearch() { - const store = useStores() +export function SearchResultCard({ + profile, + style, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + style: ViewStyle + moderation: ProfileModeration +}) { const pal = usePalette('default') + + return ( + + + + + + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + + + {sanitizeHandle(profile.handle, '@')} + + + + + ) +} + +export const DesktopSearch = observer(function DesktopSearch() { const {_} = useLingui() - const textInput = React.useRef(null) + const pal = usePalette('default') + const navigation = useNavigation() + const searchDebounceTimeout = React.useRef( + undefined, + ) const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') - const autocompleteView = React.useMemo( - () => new UserAutocompleteModel(store), - [store], - ) - const navigation = useNavigation() + 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 ( @@ -66,7 +143,6 @@ export const DesktopSearch = observer(function DesktopSearch() { /> 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 !== '' && ( - {autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((item, i) => ( - - ))} - + {searchResults.length && moderationOpts ? ( + searchResults.map((item, i) => ( + + )) ) : ( - No results found for {autocompleteView.prefix} + No results found for {query} )} @@ -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', diff --git a/yarn.lock b/yarn.lock index 9e75b3e4b2..c3d6aa6b8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"