diff options
author | Hailey <me@haileyok.com> | 2024-04-26 20:34:53 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-27 04:34:53 +0100 |
commit | 5f9136479b6dbcaaa9def57e0308326a00259e8b (patch) | |
tree | 55d660675e37508ed003654b31690b8b7ffcb674 | |
parent | d81a373d21af605db43c2076bfe486aa16e01de3 (diff) | |
download | voidsky-5f9136479b6dbcaaa9def57e0308326a00259e8b.tar.zst |
Improve usability of search on web (#3663)
* dont select the text on web * TODO REVERT THESE CHANGES * use `usethrottledvalue` for autocomplete * use `isFetching` from query * rm setTimeout * getting there * improve functionality of cancel button * rm todo * add comment back * encode `searchText` rather than `queryTerm` * use "back" on web in some cases * don't flash results in autocomplete * remove unnecesary usestate * rename everything to `query` temporarily * revert accidental lint * rm todo * rm comment * use `useFocusEffect` to update the query term on back navigation * `searchText` is always defined here * Fix race * remove back functionality * use `keepPreviousData` for query * rename `q` to `queryParam` * remove hack * remove `q=` on cancel * blur on submit * use `setParams` instead of `replace` * use `replace` on web still * clear the search input when we clear `q` on native * onPress dismiss attempt * Adjustments * Fix search history * Always hide autocomplete * Clear right pane search on select * `blur` on autosuggestion press * Rename to reduce diff * Fixes * Unify codepaths * Fixes * precache the autosuggestion * do the cache in the link card * Revert "precache the autosuggestion" This reverts commit 79c433e984621ba4231a2a4c4b3f4690b0516b4d. * use `throttledValue` and `keepPreviousData` in sidebar search * show spinner when fetching pt 1 * show spinner when fetching pt 2 * show spinner properly for autocomplete * Fix extra border * Position fixed * TS * Revert "TS" This reverts commit df187ea2d7a96d0f1832bc2392215f4d969a87c9. * Revert "Position fixed" This reverts commit 9c721c952b0fa4e5e4a23de38cab916ab13397e6. * Maybe fix iPad * Revert "TODO REVERT THESE CHANGES" This reverts commit 279f717f3091c9df8c73ba35f9a038e12f5a1122. * Rename var --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 8 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 210 | ||||
-rw-r--r-- | src/view/shell/desktop/Search.tsx | 106 |
3 files changed, 154 insertions, 170 deletions
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 0b022dd49..98b5aa17e 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 2cc0766d2..ee9e69433 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<NodeJS.Timeout | undefined>( - undefined, - ) - const [isFetching, setIsFetching] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>(props.route?.params?.q || '') - const [searchResults, setSearchResults] = React.useState< - AppBskyActorDefs.ProfileViewBasic[] - >([]) - const [inputIsFocused, setInputIsFocused] = React.useState(false) - const [showAutocompleteResults, setShowAutocompleteResults] = - React.useState(false) - const [searchHistory, setSearchHistory] = React.useState<string[]>([]) - - /** - * The Search screen's `q` param - */ - const queryParam = props.route?.params?.q + // Query terms + const queryParam = props.route?.params?.q ?? '' + const [searchText, setSearchText] = React.useState<string>(queryParam) + const {data: autocompleteData, isFetching: isAutocompleteFetching} = + useActorAutocompleteQuery(searchText, true) - /** - * 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 + const [showAutocomplete, setShowAutocomplete] = React.useState(false) + const [searchHistory, setSearchHistory] = React.useState<string[]>([]) - 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]) - - const onChangeText = React.useCallback( - async (text: string) => { - scrollToTopWeb() - - setQuery(text) - - if (text.length > 0) { - setIsFetching(true) - setShowAutocompleteResults(true) - - if (searchDebounceTimeout.current) { - clearTimeout(searchDebounceTimeout.current) - } + setSearchText('') + textInput.current?.focus() + }, []) - searchDebounceTimeout.current = setTimeout(async () => { - const results = await search({query: text, limit: 30}) + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() - 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<string>) => { - 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 ? ( <Pressable testID="searchTextInputClearBtn" onPress={onPressClearQuery} @@ -727,7 +713,7 @@ export function SearchScreen( ) : undefined} </View> - {query || inputIsFocused ? ( + {(queryParam || showAutocomplete) && ( <View style={styles.headerCancelBtn}> <Pressable onPress={onPressCancelSearch} @@ -738,12 +724,13 @@ export function SearchScreen( </Text> </Pressable> </View> - ) : undefined} + )} </CenteredView> - {showAutocompleteResults ? ( + {showAutocomplete && searchText.length > 0 ? ( <> - {isFetching || !moderationOpts ? ( + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( <Loader /> ) : ( <ScrollView @@ -753,12 +740,12 @@ export function SearchScreen( keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag"> <SearchLinkCard - label={_(msg`Search for "${query}"`)} + label={_(msg`Search for "${searchText}"`)} onPress={isNative ? onSubmit : undefined} to={ isNative ? undefined - : `/search?q=${encodeURIComponent(query)}` + : `/search?q=${encodeURIComponent(searchText)}` } style={{borderBottomWidth: 1}} /> @@ -770,11 +757,18 @@ export function SearchScreen( /> ) : null} - {searchResults.map(item => ( + {autocompleteData?.map(item => ( <SearchProfileCard key={item.did} profile={item} moderation={moderateProfile(item, moderationOpts)} + onPress={() => { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }} /> ))} @@ -782,7 +776,7 @@ export function SearchScreen( </ScrollView> )} </> - ) : !query && inputIsFocused ? ( + ) : !queryParam && showAutocomplete ? ( <CenteredView sideBorders={isTabletOrDesktop} // @ts-ignore web only -prf @@ -826,10 +820,8 @@ export function SearchScreen( )} </View> </CenteredView> - ) : routeParamsMismatch ? ( - <ActivityIndicator /> ) : ( - <SearchScreenInner query={query} /> + <SearchScreenInner query={queryParam} /> )} </View> ) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 0c5bd452f..52f28cc63 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, + ActivityIndicator, StyleSheet, + TextInput, TouchableOpacity, - ActivityIndicator, + 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 ( <Link @@ -96,7 +106,8 @@ export function SearchProfileCard({ href={makeProfileLink(profile)} title={profile.handle} asAnchor - anchorNoUnderline> + anchorNoUnderline + onBeforePress={onPress}> <View style={[ pal.border, @@ -138,63 +149,35 @@ export function DesktopSearch() { const {_} = useLingui() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() - const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( - undefined, - ) const [isActive, setIsActive] = React.useState<boolean>(false) - const [isFetching, setIsFetching] = React.useState<boolean>(false) const [query, setQuery] = React.useState<string>('') - 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 && ( <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> - {isFetching ? ( + {isFetching && !autocompleteData?.length ? ( <View style={{padding: 8}}> <ActivityIndicator /> </View> @@ -255,7 +238,11 @@ export function DesktopSearch() { <SearchLinkCard label={_(msg`Search for "${query}"`)} to={`/search?q=${encodeURIComponent(query)}`} - style={{borderBottomWidth: 1}} + style={ + queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 + ? {borderBottomWidth: 1} + : undefined + } /> {queryMaybeHandle ? ( @@ -265,11 +252,12 @@ export function DesktopSearch() { /> ) : null} - {searchResults.map(item => ( + {autocompleteData?.map(item => ( <SearchProfileCard key={item.did} profile={item} moderation={moderateProfile(item, moderationOpts)} + onPress={onSearchProfileCardPress} /> ))} </> |