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"