diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 0b022dd4..98b5aa17 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -1,6 +1,6 @@ import React from 'react' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' -import {useQuery, useQueryClient} from '@tanstack/react-query' +import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' import {isJustAMute} from '#/lib/moderation' import {logger} from '#/logger' @@ -16,7 +16,10 @@ const DEFAULT_MOD_OPTS = { const RQKEY_ROOT = 'actor-autocomplete' export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix] -export function useActorAutocompleteQuery(prefix: string) { +export function useActorAutocompleteQuery( + prefix: string, + maintainData?: boolean, +) { const moderationOpts = useModerationOpts() const {getAgent} = useAgent() @@ -40,6 +43,7 @@ export function useActorAutocompleteQuery(prefix: string) { }, [moderationOpts], ), + placeholderData: maintainData ? keepPreviousData : undefined, }) } diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 2cc0766d..ee9e6943 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -27,7 +27,7 @@ import {s} from '#/lib/styles' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' -import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' import {useModerationOpts} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' @@ -35,6 +35,7 @@ import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {useNonReactiveCallback} from 'lib/hooks/useNonReactiveCallback' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import { NativeStackScreenProps, @@ -308,7 +309,7 @@ function SearchScreenUserResults({ const {_} = useLingui() const {data: results, isFetched} = useActorSearch({ - query, + query: query, enabled: active, }) @@ -478,43 +479,25 @@ export function SearchScreen( const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() const moderationOpts = useModerationOpts() - const search = useActorAutocompleteFn() const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() - const searchDebounceTimeout = React.useRef( - undefined, - ) - const [isFetching, setIsFetching] = React.useState(false) - const [query, setQuery] = React.useState(props.route?.params?.q || '') - const [searchResults, setSearchResults] = React.useState< - AppBskyActorDefs.ProfileViewBasic[] - >([]) - const [inputIsFocused, setInputIsFocused] = React.useState(false) - const [showAutocompleteResults, setShowAutocompleteResults] = - React.useState(false) + // Query terms + const queryParam = props.route?.params?.q ?? '' + const [searchText, setSearchText] = React.useState(queryParam) + const {data: autocompleteData, isFetching: isAutocompleteFetching} = + useActorAutocompleteQuery(searchText, true) + + const [showAutocomplete, setShowAutocomplete] = React.useState(false) const [searchHistory, setSearchHistory] = React.useState([]) - /** - * The Search screen's `q` param - */ - const queryParam = props.route?.params?.q - - /** - * If `true`, this means we received new instructions from the router. This - * is handled in a effect, and used to update the value of `query` locally - * within this screen. - */ - const routeParamsMismatch = queryParam && queryParam !== query - - React.useEffect(() => { - if (queryParam && routeParamsMismatch) { - // reset immediately and let local state take over - navigation.setParams({q: ''}) - // update query for next search - setQuery(queryParam) - } - }, [queryParam, routeParamsMismatch, navigation]) + useFocusEffect( + useNonReactiveCallback(() => { + if (isWeb) { + setSearchText(queryParam) + } + }), + ) React.useEffect(() => { const loadSearchHistory = async () => { @@ -536,60 +519,45 @@ export function SearchScreen( setDrawerOpen(true) }, [track, setDrawerOpen]) - const onPressCancelSearch = React.useCallback(() => { - scrollToTopWeb() - textInput.current?.blur() - setQuery('') - setShowAutocompleteResults(false) - if (searchDebounceTimeout.current) - clearTimeout(searchDebounceTimeout.current) - }, [textInput]) - const onPressClearQuery = React.useCallback(() => { scrollToTopWeb() - setQuery('') - setShowAutocompleteResults(false) - }, [setQuery]) + setSearchText('') + textInput.current?.focus() + }, []) - const onChangeText = React.useCallback( - async (text: string) => { - scrollToTopWeb() + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() - setQuery(text) - - if (text.length > 0) { - setIsFetching(true) - setShowAutocompleteResults(true) - - if (searchDebounceTimeout.current) { - clearTimeout(searchDebounceTimeout.current) - } - - searchDebounceTimeout.current = setTimeout(async () => { - const results = await search({query: text, limit: 30}) - - if (results) { - setSearchResults(results) - setIsFetching(false) - } - }, 300) + if (showAutocomplete) { + textInput.current?.blur() + setShowAutocomplete(false) + setSearchText(queryParam) + } else { + // If we just `setParams` and set `q` to an empty string, the URL still displays `q=`, which isn't pretty. + // However, `.replace()` on native has a "push" animation that we don't want. So we need to handle these + // differently. + if (isWeb) { + navigation.replace('Search', {}) } else { - if (searchDebounceTimeout.current) { - clearTimeout(searchDebounceTimeout.current) - } - setSearchResults([]) - setIsFetching(false) - setShowAutocompleteResults(false) + setSearchText('') + navigation.setParams({q: ''}) } - }, - [setQuery, search, setSearchResults], - ) + } + }, [showAutocomplete, navigation, queryParam]) + + const onChangeText = React.useCallback(async (text: string) => { + scrollToTopWeb() + setSearchText(text) + }, []) const updateSearchHistory = React.useCallback( async (newQuery: string) => { newQuery = newQuery.trim() - if (newQuery && !searchHistory.includes(newQuery)) { - let newHistory = [newQuery, ...searchHistory] + if (newQuery) { + let newHistory = [ + newQuery, + ...searchHistory.filter(q => q !== newQuery), + ] if (newHistory.length > 5) { newHistory = newHistory.slice(0, 5) @@ -609,11 +577,30 @@ export function SearchScreen( [searchHistory, setSearchHistory], ) + const navigateToItem = React.useCallback( + (item: string) => { + scrollToTopWeb() + setShowAutocomplete(false) + updateSearchHistory(item) + + if (isWeb) { + navigation.push('Search', {q: item}) + } else { + textInput.current?.blur() + navigation.setParams({q: item}) + } + }, + [updateSearchHistory, navigation], + ) + const onSubmit = React.useCallback(() => { - scrollToTopWeb() - setShowAutocompleteResults(false) - updateSearchHistory(query) - }, [query, setShowAutocompleteResults, updateSearchHistory]) + navigateToItem(searchText) + }, [navigateToItem, searchText]) + + const handleHistoryItemClick = (item: string) => { + setSearchText(item) + navigateToItem(item) + } const onSoftReset = React.useCallback(() => { scrollToTopWeb() @@ -621,9 +608,9 @@ export function SearchScreen( }, [onPressCancelSearch]) const queryMaybeHandle = React.useMemo(() => { - const match = MATCH_HANDLE.exec(query) + const match = MATCH_HANDLE.exec(queryParam) return match && match[1] - }, [query]) + }, [queryParam]) useFocusEffect( React.useCallback(() => { @@ -632,11 +619,6 @@ export function SearchScreen( }, [onSoftReset, setMinimalShellMode]), ) - const handleHistoryItemClick = (item: React.SetStateAction) => { - setQuery(item) - onSubmit() - } - const handleRemoveHistoryItem = (itemToRemove: string) => { const updatedHistory = searchHistory.filter(item => item !== itemToRemove) setSearchHistory(updatedHistory) @@ -688,17 +670,21 @@ export function SearchScreen( ref={textInput} placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} - selectTextOnFocus + selectTextOnFocus={isNative} returnKeyType="search" - value={query} + value={searchText} style={[pal.text, styles.headerSearchInput]} keyboardAppearance={theme.colorScheme} - onFocus={() => setInputIsFocused(true)} - onBlur={() => { - // HACK - // give 100ms to not stop click handlers in the search history - // -prf - setTimeout(() => setInputIsFocused(false), 100) + onFocus={() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + } }} onChangeText={onChangeText} onSubmitEditing={onSubmit} @@ -710,7 +696,7 @@ export function SearchScreen( autoComplete="off" autoCapitalize="none" /> - {query ? ( + {showAutocomplete ? ( - {query || inputIsFocused ? ( + {(queryParam || showAutocomplete) && ( - ) : undefined} + )} - {showAutocompleteResults ? ( + {showAutocomplete && searchText.length > 0 ? ( <> - {isFetching || !moderationOpts ? ( + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( ) : ( @@ -770,11 +757,18 @@ export function SearchScreen( /> ) : null} - {searchResults.map(item => ( + {autocompleteData?.map(item => ( { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }} /> ))} @@ -782,7 +776,7 @@ export function SearchScreen( )} - ) : !query && inputIsFocused ? ( + ) : !queryParam && showAutocomplete ? ( - ) : routeParamsMismatch ? ( - ) : ( - + )} ) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 0c5bd452..52f28cc6 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,33 +1,35 @@ import React from 'react' import { - ViewStyle, - TextInput, - View, - StyleSheet, - TouchableOpacity, ActivityIndicator, + StyleSheet, + TextInput, + TouchableOpacity, + View, + ViewStyle, } from 'react-native' -import {useNavigation, StackActions} from '@react-navigation/native' import { AppBskyActorDefs, moderateProfile, ModerationDecision, } from '@atproto/api' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {StackActions, useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' -import {s} from '#/lib/styles' +import {makeProfileLink} from '#/lib/routes/links' 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 {s} from '#/lib/styles' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useModerationOpts} from '#/state/queries/preferences' 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 {precacheProfile} from 'state/queries/profile' +import {Link} from '#/view/com/util/Link' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {useModerationOpts} from '#/state/queries/preferences' +import {Text} from 'view/com/util/text/Text' export const MATCH_HANDLE = /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ @@ -84,11 +86,19 @@ export function SearchLinkCard({ export function SearchProfileCard({ profile, moderation, + onPress: onPressInner, }: { profile: AppBskyActorDefs.ProfileViewBasic moderation: ModerationDecision + onPress: () => void }) { const pal = usePalette('default') + const queryClient = useQueryClient() + + const onPress = React.useCallback(() => { + precacheProfile(queryClient, profile) + onPressInner() + }, [queryClient, profile, onPressInner]) return ( + anchorNoUnderline + onBeforePress={onPress}> () - const searchDebounceTimeout = React.useRef( - undefined, - ) const [isActive, setIsActive] = React.useState(false) - const [isFetching, setIsFetching] = React.useState(false) const [query, setQuery] = React.useState('') - const [searchResults, setSearchResults] = React.useState< - AppBskyActorDefs.ProfileViewBasic[] - >([]) + const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( + query, + true, + ) const moderationOpts = useModerationOpts() - const search = useActorAutocompleteFn() - const onChangeText = React.useCallback( - async (text: string) => { - setQuery(text) - - if (text.length > 0) { - setIsFetching(true) - setIsActive(true) - - if (searchDebounceTimeout.current) - clearTimeout(searchDebounceTimeout.current) - - searchDebounceTimeout.current = setTimeout(async () => { - const results = await search({query: text}) - - if (results) { - setSearchResults(results) - setIsFetching(false) - } - }, 300) - } else { - if (searchDebounceTimeout.current) - clearTimeout(searchDebounceTimeout.current) - setSearchResults([]) - setIsFetching(false) - setIsActive(false) - } - }, - [setQuery, search, setSearchResults], - ) + const onChangeText = React.useCallback((text: string) => { + setQuery(text) + setIsActive(text.length > 0) + }, []) const onPressCancelSearch = React.useCallback(() => { setQuery('') setIsActive(false) - if (searchDebounceTimeout.current) - clearTimeout(searchDebounceTimeout.current) }, [setQuery]) + const onSubmit = React.useCallback(() => { setIsActive(false) if (!query.length) return - setSearchResults([]) - if (searchDebounceTimeout.current) - clearTimeout(searchDebounceTimeout.current) navigation.dispatch(StackActions.push('Search', {q: query})) - }, [query, navigation, setSearchResults]) + }, [query, navigation]) + + const onSearchProfileCardPress = React.useCallback(() => { + setQuery('') + setIsActive(false) + }, []) const queryMaybeHandle = React.useMemo(() => { const match = MATCH_HANDLE.exec(query) @@ -246,7 +229,7 @@ export function DesktopSearch() { {query !== '' && isActive && moderationOpts && ( - {isFetching ? ( + {isFetching && !autocompleteData?.length ? ( @@ -255,7 +238,11 @@ export function DesktopSearch() { 0 + ? {borderBottomWidth: 1} + : undefined + } /> {queryMaybeHandle ? ( @@ -265,11 +252,12 @@ export function DesktopSearch() { /> ) : null} - {searchResults.map(item => ( + {autocompleteData?.map(item => ( ))}