Bluesky app fork with some witchin' additions 💫

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>

authored by hailey.at

Dan Abramov and committed by
GitHub
5f913647 d81a373d

+154 -170
+6 -2
src/state/queries/actor-autocomplete.ts
··· 1 1 import React from 'react' 2 2 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 3 - import {useQuery, useQueryClient} from '@tanstack/react-query' 3 + import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' 4 4 5 5 import {isJustAMute} from '#/lib/moderation' 6 6 import {logger} from '#/logger' ··· 16 16 const RQKEY_ROOT = 'actor-autocomplete' 17 17 export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix] 18 18 19 - export function useActorAutocompleteQuery(prefix: string) { 19 + export function useActorAutocompleteQuery( 20 + prefix: string, 21 + maintainData?: boolean, 22 + ) { 20 23 const moderationOpts = useModerationOpts() 21 24 const {getAgent} = useAgent() 22 25 ··· 40 43 }, 41 44 [moderationOpts], 42 45 ), 46 + placeholderData: maintainData ? keepPreviousData : undefined, 43 47 }) 44 48 } 45 49
+101 -109
src/view/screens/Search/Search.tsx
··· 27 27 import {logger} from '#/logger' 28 28 import {isNative, isWeb} from '#/platform/detection' 29 29 import {listenSoftReset} from '#/state/events' 30 - import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 30 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 31 31 import {useActorSearch} from '#/state/queries/actor-search' 32 32 import {useModerationOpts} from '#/state/queries/preferences' 33 33 import {useSearchPostsQuery} from '#/state/queries/search-posts' ··· 35 35 import {useSession} from '#/state/session' 36 36 import {useSetDrawerOpen} from '#/state/shell' 37 37 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' 38 + import {useNonReactiveCallback} from 'lib/hooks/useNonReactiveCallback' 38 39 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 39 40 import { 40 41 NativeStackScreenProps, ··· 308 309 const {_} = useLingui() 309 310 310 311 const {data: results, isFetched} = useActorSearch({ 311 - query, 312 + query: query, 312 313 enabled: active, 313 314 }) 314 315 ··· 478 479 const {track} = useAnalytics() 479 480 const setDrawerOpen = useSetDrawerOpen() 480 481 const moderationOpts = useModerationOpts() 481 - const search = useActorAutocompleteFn() 482 482 const setMinimalShellMode = useSetMinimalShellMode() 483 483 const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() 484 484 485 - const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( 486 - undefined, 487 - ) 488 - const [isFetching, setIsFetching] = React.useState<boolean>(false) 489 - const [query, setQuery] = React.useState<string>(props.route?.params?.q || '') 490 - const [searchResults, setSearchResults] = React.useState< 491 - AppBskyActorDefs.ProfileViewBasic[] 492 - >([]) 493 - const [inputIsFocused, setInputIsFocused] = React.useState(false) 494 - const [showAutocompleteResults, setShowAutocompleteResults] = 495 - React.useState(false) 496 - const [searchHistory, setSearchHistory] = React.useState<string[]>([]) 497 - 498 - /** 499 - * The Search screen's `q` param 500 - */ 501 - const queryParam = props.route?.params?.q 485 + // Query terms 486 + const queryParam = props.route?.params?.q ?? '' 487 + const [searchText, setSearchText] = React.useState<string>(queryParam) 488 + const {data: autocompleteData, isFetching: isAutocompleteFetching} = 489 + useActorAutocompleteQuery(searchText, true) 502 490 503 - /** 504 - * If `true`, this means we received new instructions from the router. This 505 - * is handled in a effect, and used to update the value of `query` locally 506 - * within this screen. 507 - */ 508 - const routeParamsMismatch = queryParam && queryParam !== query 491 + const [showAutocomplete, setShowAutocomplete] = React.useState(false) 492 + const [searchHistory, setSearchHistory] = React.useState<string[]>([]) 509 493 510 - React.useEffect(() => { 511 - if (queryParam && routeParamsMismatch) { 512 - // reset immediately and let local state take over 513 - navigation.setParams({q: ''}) 514 - // update query for next search 515 - setQuery(queryParam) 516 - } 517 - }, [queryParam, routeParamsMismatch, navigation]) 494 + useFocusEffect( 495 + useNonReactiveCallback(() => { 496 + if (isWeb) { 497 + setSearchText(queryParam) 498 + } 499 + }), 500 + ) 518 501 519 502 React.useEffect(() => { 520 503 const loadSearchHistory = async () => { ··· 536 519 setDrawerOpen(true) 537 520 }, [track, setDrawerOpen]) 538 521 539 - const onPressCancelSearch = React.useCallback(() => { 522 + const onPressClearQuery = React.useCallback(() => { 540 523 scrollToTopWeb() 541 - textInput.current?.blur() 542 - setQuery('') 543 - setShowAutocompleteResults(false) 544 - if (searchDebounceTimeout.current) 545 - clearTimeout(searchDebounceTimeout.current) 546 - }, [textInput]) 524 + setSearchText('') 525 + textInput.current?.focus() 526 + }, []) 547 527 548 - const onPressClearQuery = React.useCallback(() => { 528 + const onPressCancelSearch = React.useCallback(() => { 549 529 scrollToTopWeb() 550 - setQuery('') 551 - setShowAutocompleteResults(false) 552 - }, [setQuery]) 553 530 554 - const onChangeText = React.useCallback( 555 - async (text: string) => { 556 - scrollToTopWeb() 557 - 558 - setQuery(text) 559 - 560 - if (text.length > 0) { 561 - setIsFetching(true) 562 - setShowAutocompleteResults(true) 563 - 564 - if (searchDebounceTimeout.current) { 565 - clearTimeout(searchDebounceTimeout.current) 566 - } 567 - 568 - searchDebounceTimeout.current = setTimeout(async () => { 569 - const results = await search({query: text, limit: 30}) 570 - 571 - if (results) { 572 - setSearchResults(results) 573 - setIsFetching(false) 574 - } 575 - }, 300) 531 + if (showAutocomplete) { 532 + textInput.current?.blur() 533 + setShowAutocomplete(false) 534 + setSearchText(queryParam) 535 + } else { 536 + // If we just `setParams` and set `q` to an empty string, the URL still displays `q=`, which isn't pretty. 537 + // However, `.replace()` on native has a "push" animation that we don't want. So we need to handle these 538 + // differently. 539 + if (isWeb) { 540 + navigation.replace('Search', {}) 576 541 } else { 577 - if (searchDebounceTimeout.current) { 578 - clearTimeout(searchDebounceTimeout.current) 579 - } 580 - setSearchResults([]) 581 - setIsFetching(false) 582 - setShowAutocompleteResults(false) 542 + setSearchText('') 543 + navigation.setParams({q: ''}) 583 544 } 584 - }, 585 - [setQuery, search, setSearchResults], 586 - ) 545 + } 546 + }, [showAutocomplete, navigation, queryParam]) 547 + 548 + const onChangeText = React.useCallback(async (text: string) => { 549 + scrollToTopWeb() 550 + setSearchText(text) 551 + }, []) 587 552 588 553 const updateSearchHistory = React.useCallback( 589 554 async (newQuery: string) => { 590 555 newQuery = newQuery.trim() 591 - if (newQuery && !searchHistory.includes(newQuery)) { 592 - let newHistory = [newQuery, ...searchHistory] 556 + if (newQuery) { 557 + let newHistory = [ 558 + newQuery, 559 + ...searchHistory.filter(q => q !== newQuery), 560 + ] 593 561 594 562 if (newHistory.length > 5) { 595 563 newHistory = newHistory.slice(0, 5) ··· 609 577 [searchHistory, setSearchHistory], 610 578 ) 611 579 580 + const navigateToItem = React.useCallback( 581 + (item: string) => { 582 + scrollToTopWeb() 583 + setShowAutocomplete(false) 584 + updateSearchHistory(item) 585 + 586 + if (isWeb) { 587 + navigation.push('Search', {q: item}) 588 + } else { 589 + textInput.current?.blur() 590 + navigation.setParams({q: item}) 591 + } 592 + }, 593 + [updateSearchHistory, navigation], 594 + ) 595 + 612 596 const onSubmit = React.useCallback(() => { 613 - scrollToTopWeb() 614 - setShowAutocompleteResults(false) 615 - updateSearchHistory(query) 616 - }, [query, setShowAutocompleteResults, updateSearchHistory]) 597 + navigateToItem(searchText) 598 + }, [navigateToItem, searchText]) 599 + 600 + const handleHistoryItemClick = (item: string) => { 601 + setSearchText(item) 602 + navigateToItem(item) 603 + } 617 604 618 605 const onSoftReset = React.useCallback(() => { 619 606 scrollToTopWeb() ··· 621 608 }, [onPressCancelSearch]) 622 609 623 610 const queryMaybeHandle = React.useMemo(() => { 624 - const match = MATCH_HANDLE.exec(query) 611 + const match = MATCH_HANDLE.exec(queryParam) 625 612 return match && match[1] 626 - }, [query]) 613 + }, [queryParam]) 627 614 628 615 useFocusEffect( 629 616 React.useCallback(() => { ··· 632 619 }, [onSoftReset, setMinimalShellMode]), 633 620 ) 634 621 635 - const handleHistoryItemClick = (item: React.SetStateAction<string>) => { 636 - setQuery(item) 637 - onSubmit() 638 - } 639 - 640 622 const handleRemoveHistoryItem = (itemToRemove: string) => { 641 623 const updatedHistory = searchHistory.filter(item => item !== itemToRemove) 642 624 setSearchHistory(updatedHistory) ··· 688 670 ref={textInput} 689 671 placeholder={_(msg`Search`)} 690 672 placeholderTextColor={pal.colors.textLight} 691 - selectTextOnFocus 673 + selectTextOnFocus={isNative} 692 674 returnKeyType="search" 693 - value={query} 675 + value={searchText} 694 676 style={[pal.text, styles.headerSearchInput]} 695 677 keyboardAppearance={theme.colorScheme} 696 - onFocus={() => setInputIsFocused(true)} 697 - onBlur={() => { 698 - // HACK 699 - // give 100ms to not stop click handlers in the search history 700 - // -prf 701 - setTimeout(() => setInputIsFocused(false), 100) 678 + onFocus={() => { 679 + if (isWeb) { 680 + // Prevent a jump on iPad by ensuring that 681 + // the initial focused render has no result list. 682 + requestAnimationFrame(() => { 683 + setShowAutocomplete(true) 684 + }) 685 + } else { 686 + setShowAutocomplete(true) 687 + } 702 688 }} 703 689 onChangeText={onChangeText} 704 690 onSubmitEditing={onSubmit} ··· 710 696 autoComplete="off" 711 697 autoCapitalize="none" 712 698 /> 713 - {query ? ( 699 + {showAutocomplete ? ( 714 700 <Pressable 715 701 testID="searchTextInputClearBtn" 716 702 onPress={onPressClearQuery} ··· 727 713 ) : undefined} 728 714 </View> 729 715 730 - {query || inputIsFocused ? ( 716 + {(queryParam || showAutocomplete) && ( 731 717 <View style={styles.headerCancelBtn}> 732 718 <Pressable 733 719 onPress={onPressCancelSearch} ··· 738 724 </Text> 739 725 </Pressable> 740 726 </View> 741 - ) : undefined} 727 + )} 742 728 </CenteredView> 743 729 744 - {showAutocompleteResults ? ( 730 + {showAutocomplete && searchText.length > 0 ? ( 745 731 <> 746 - {isFetching || !moderationOpts ? ( 732 + {(isAutocompleteFetching && !autocompleteData?.length) || 733 + !moderationOpts ? ( 747 734 <Loader /> 748 735 ) : ( 749 736 <ScrollView ··· 753 740 keyboardShouldPersistTaps="handled" 754 741 keyboardDismissMode="on-drag"> 755 742 <SearchLinkCard 756 - label={_(msg`Search for "${query}"`)} 743 + label={_(msg`Search for "${searchText}"`)} 757 744 onPress={isNative ? onSubmit : undefined} 758 745 to={ 759 746 isNative 760 747 ? undefined 761 - : `/search?q=${encodeURIComponent(query)}` 748 + : `/search?q=${encodeURIComponent(searchText)}` 762 749 } 763 750 style={{borderBottomWidth: 1}} 764 751 /> ··· 770 757 /> 771 758 ) : null} 772 759 773 - {searchResults.map(item => ( 760 + {autocompleteData?.map(item => ( 774 761 <SearchProfileCard 775 762 key={item.did} 776 763 profile={item} 777 764 moderation={moderateProfile(item, moderationOpts)} 765 + onPress={() => { 766 + if (isWeb) { 767 + setShowAutocomplete(false) 768 + } else { 769 + textInput.current?.blur() 770 + } 771 + }} 778 772 /> 779 773 ))} 780 774 ··· 782 776 </ScrollView> 783 777 )} 784 778 </> 785 - ) : !query && inputIsFocused ? ( 779 + ) : !queryParam && showAutocomplete ? ( 786 780 <CenteredView 787 781 sideBorders={isTabletOrDesktop} 788 782 // @ts-ignore web only -prf ··· 826 820 )} 827 821 </View> 828 822 </CenteredView> 829 - ) : routeParamsMismatch ? ( 830 - <ActivityIndicator /> 831 823 ) : ( 832 - <SearchScreenInner query={query} /> 824 + <SearchScreenInner query={queryParam} /> 833 825 )} 834 826 </View> 835 827 )
+47 -59
src/view/shell/desktop/Search.tsx
··· 1 1 import React from 'react' 2 2 import { 3 - ViewStyle, 3 + ActivityIndicator, 4 + StyleSheet, 4 5 TextInput, 6 + TouchableOpacity, 5 7 View, 6 - StyleSheet, 7 - TouchableOpacity, 8 - ActivityIndicator, 8 + ViewStyle, 9 9 } from 'react-native' 10 - import {useNavigation, StackActions} from '@react-navigation/native' 11 10 import { 12 11 AppBskyActorDefs, 13 12 moderateProfile, 14 13 ModerationDecision, 15 14 } from '@atproto/api' 16 - import {Trans, msg} from '@lingui/macro' 15 + import {msg, Trans} from '@lingui/macro' 17 16 import {useLingui} from '@lingui/react' 17 + import {StackActions, useNavigation} from '@react-navigation/native' 18 + import {useQueryClient} from '@tanstack/react-query' 18 19 19 - import {s} from '#/lib/styles' 20 + import {makeProfileLink} from '#/lib/routes/links' 20 21 import {sanitizeDisplayName} from '#/lib/strings/display-names' 21 22 import {sanitizeHandle} from '#/lib/strings/handles' 22 - import {makeProfileLink} from '#/lib/routes/links' 23 - import {Link} from '#/view/com/util/Link' 23 + import {s} from '#/lib/styles' 24 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 25 + import {useModerationOpts} from '#/state/queries/preferences' 24 26 import {usePalette} from 'lib/hooks/usePalette' 25 27 import {MagnifyingGlassIcon2} from 'lib/icons' 26 28 import {NavigationProp} from 'lib/routes/types' 27 - import {Text} from 'view/com/util/text/Text' 29 + import {precacheProfile} from 'state/queries/profile' 30 + import {Link} from '#/view/com/util/Link' 28 31 import {UserAvatar} from '#/view/com/util/UserAvatar' 29 - import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 30 - import {useModerationOpts} from '#/state/queries/preferences' 32 + import {Text} from 'view/com/util/text/Text' 31 33 32 34 export const MATCH_HANDLE = 33 35 /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ ··· 84 86 export function SearchProfileCard({ 85 87 profile, 86 88 moderation, 89 + onPress: onPressInner, 87 90 }: { 88 91 profile: AppBskyActorDefs.ProfileViewBasic 89 92 moderation: ModerationDecision 93 + onPress: () => void 90 94 }) { 91 95 const pal = usePalette('default') 96 + const queryClient = useQueryClient() 97 + 98 + const onPress = React.useCallback(() => { 99 + precacheProfile(queryClient, profile) 100 + onPressInner() 101 + }, [queryClient, profile, onPressInner]) 92 102 93 103 return ( 94 104 <Link ··· 96 106 href={makeProfileLink(profile)} 97 107 title={profile.handle} 98 108 asAnchor 99 - anchorNoUnderline> 109 + anchorNoUnderline 110 + onBeforePress={onPress}> 100 111 <View 101 112 style={[ 102 113 pal.border, ··· 138 149 const {_} = useLingui() 139 150 const pal = usePalette('default') 140 151 const navigation = useNavigation<NavigationProp>() 141 - const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( 142 - undefined, 143 - ) 144 152 const [isActive, setIsActive] = React.useState<boolean>(false) 145 - const [isFetching, setIsFetching] = React.useState<boolean>(false) 146 153 const [query, setQuery] = React.useState<string>('') 147 - const [searchResults, setSearchResults] = React.useState< 148 - AppBskyActorDefs.ProfileViewBasic[] 149 - >([]) 154 + const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( 155 + query, 156 + true, 157 + ) 150 158 151 159 const moderationOpts = useModerationOpts() 152 - const search = useActorAutocompleteFn() 153 - 154 - const onChangeText = React.useCallback( 155 - async (text: string) => { 156 - setQuery(text) 157 160 158 - if (text.length > 0) { 159 - setIsFetching(true) 160 - setIsActive(true) 161 - 162 - if (searchDebounceTimeout.current) 163 - clearTimeout(searchDebounceTimeout.current) 164 - 165 - searchDebounceTimeout.current = setTimeout(async () => { 166 - const results = await search({query: text}) 167 - 168 - if (results) { 169 - setSearchResults(results) 170 - setIsFetching(false) 171 - } 172 - }, 300) 173 - } else { 174 - if (searchDebounceTimeout.current) 175 - clearTimeout(searchDebounceTimeout.current) 176 - setSearchResults([]) 177 - setIsFetching(false) 178 - setIsActive(false) 179 - } 180 - }, 181 - [setQuery, search, setSearchResults], 182 - ) 161 + const onChangeText = React.useCallback((text: string) => { 162 + setQuery(text) 163 + setIsActive(text.length > 0) 164 + }, []) 183 165 184 166 const onPressCancelSearch = React.useCallback(() => { 185 167 setQuery('') 186 168 setIsActive(false) 187 - if (searchDebounceTimeout.current) 188 - clearTimeout(searchDebounceTimeout.current) 189 169 }, [setQuery]) 170 + 190 171 const onSubmit = React.useCallback(() => { 191 172 setIsActive(false) 192 173 if (!query.length) return 193 - setSearchResults([]) 194 - if (searchDebounceTimeout.current) 195 - clearTimeout(searchDebounceTimeout.current) 196 174 navigation.dispatch(StackActions.push('Search', {q: query})) 197 - }, [query, navigation, setSearchResults]) 175 + }, [query, navigation]) 176 + 177 + const onSearchProfileCardPress = React.useCallback(() => { 178 + setQuery('') 179 + setIsActive(false) 180 + }, []) 198 181 199 182 const queryMaybeHandle = React.useMemo(() => { 200 183 const match = MATCH_HANDLE.exec(query) ··· 246 229 247 230 {query !== '' && isActive && moderationOpts && ( 248 231 <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> 249 - {isFetching ? ( 232 + {isFetching && !autocompleteData?.length ? ( 250 233 <View style={{padding: 8}}> 251 234 <ActivityIndicator /> 252 235 </View> ··· 255 238 <SearchLinkCard 256 239 label={_(msg`Search for "${query}"`)} 257 240 to={`/search?q=${encodeURIComponent(query)}`} 258 - style={{borderBottomWidth: 1}} 241 + style={ 242 + queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 243 + ? {borderBottomWidth: 1} 244 + : undefined 245 + } 259 246 /> 260 247 261 248 {queryMaybeHandle ? ( ··· 265 252 /> 266 253 ) : null} 267 254 268 - {searchResults.map(item => ( 255 + {autocompleteData?.map(item => ( 269 256 <SearchProfileCard 270 257 key={item.did} 271 258 profile={item} 272 259 moderation={moderateProfile(item, moderationOpts)} 260 + onPress={onSearchProfileCardPress} 273 261 /> 274 262 ))} 275 263 </>