Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve search screen perf (#3752)

* Extract SearchHistory to a component

* Extract AutocompleteResults to a component

* Extract SearchInputBox to a component

* Add a bunch of memoization

* Optimize switching by rendering both

* Remove subdomain matching

This is only ever useful if you type it exactly correct. Search now does a better job anyway.

* Give recent search decent hitslops

authored by

dan and committed by
GitHub
5d715ae1 3c2d7390

+292 -219
+283 -198
src/view/screens/Search/Search.tsx
··· 49 49 import {List} from '#/view/com/util/List' 50 50 import {Text} from '#/view/com/util/text/Text' 51 51 import {CenteredView, ScrollView} from '#/view/com/util/Views' 52 - import { 53 - MATCH_HANDLE, 54 - SearchLinkCard, 55 - SearchProfileCard, 56 - } from '#/view/shell/desktop/Search' 52 + import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 57 53 import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 58 54 import {atoms as a} from '#/alf' 59 55 ··· 156 152 return [items, onEndReached] 157 153 } 158 154 159 - function SearchScreenSuggestedFollows() { 155 + let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { 160 156 const pal = usePalette('default') 161 157 const [suggestions, onEndReached] = useSuggestedFollows() 162 158 ··· 180 176 </CenteredView> 181 177 ) 182 178 } 179 + SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) 183 180 184 181 type SearchResultSlice = 185 182 | { ··· 192 189 key: string 193 190 } 194 191 195 - function SearchScreenPostResults({ 192 + let SearchScreenPostResults = ({ 196 193 query, 197 194 sort, 198 195 active, ··· 200 197 query: string 201 198 sort?: 'top' | 'latest' 202 199 active: boolean 203 - }) { 200 + }): React.ReactNode => { 204 201 const {_} = useLingui() 205 202 const {currentAccount} = useSession() 206 203 const [isPTR, setIsPTR] = React.useState(false) ··· 298 295 </> 299 296 ) 300 297 } 298 + SearchScreenPostResults = React.memo(SearchScreenPostResults) 301 299 302 - function SearchScreenUserResults({ 300 + let SearchScreenUserResults = ({ 303 301 query, 304 302 active, 305 303 }: { 306 304 query: string 307 305 active: boolean 308 - }) { 306 + }): React.ReactNode => { 309 307 const {_} = useLingui() 310 308 311 309 const {data: results, isFetched} = useActorSearch({ ··· 334 332 <Loader /> 335 333 ) 336 334 } 335 + SearchScreenUserResults = React.memo(SearchScreenUserResults) 337 336 338 - export function SearchScreenInner({query}: {query?: string}) { 337 + let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { 339 338 const pal = usePalette('default') 340 339 const setMinimalShellMode = useSetMinimalShellMode() 341 340 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() ··· 467 466 </CenteredView> 468 467 ) 469 468 } 469 + SearchScreenInner = React.memo(SearchScreenInner) 470 470 471 471 export function SearchScreen( 472 472 props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 473 473 ) { 474 474 const navigation = useNavigation<NavigationProp>() 475 - const theme = useTheme() 476 475 const textInput = React.useRef<TextInput>(null) 477 476 const {_} = useLingui() 478 477 const pal = usePalette('default') 479 478 const {track} = useAnalytics() 480 479 const setDrawerOpen = useSetDrawerOpen() 481 - const moderationOpts = useModerationOpts() 482 480 const setMinimalShellMode = useSetMinimalShellMode() 483 481 const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() 484 482 ··· 584 582 navigateToItem(searchText) 585 583 }, [navigateToItem, searchText]) 586 584 587 - const handleHistoryItemClick = (item: string) => { 588 - setSearchText(item) 589 - navigateToItem(item) 590 - } 585 + const onAutocompleteResultPress = React.useCallback(() => { 586 + if (isWeb) { 587 + setShowAutocomplete(false) 588 + } else { 589 + textInput.current?.blur() 590 + } 591 + }, []) 592 + 593 + const handleHistoryItemClick = React.useCallback( 594 + (item: string) => { 595 + setSearchText(item) 596 + navigateToItem(item) 597 + }, 598 + [navigateToItem], 599 + ) 591 600 592 601 const onSoftReset = React.useCallback(() => { 593 602 scrollToTopWeb() 594 603 onPressCancelSearch() 595 604 }, [onPressCancelSearch]) 596 605 597 - const queryMaybeHandle = React.useMemo(() => { 598 - const match = MATCH_HANDLE.exec(queryParam) 599 - return match && match[1] 600 - }, [queryParam]) 601 - 602 606 useFocusEffect( 603 607 React.useCallback(() => { 604 608 setMinimalShellMode(false) ··· 606 610 }, [onSoftReset, setMinimalShellMode]), 607 611 ) 608 612 609 - const handleRemoveHistoryItem = (itemToRemove: string) => { 610 - const updatedHistory = searchHistory.filter(item => item !== itemToRemove) 611 - setSearchHistory(updatedHistory) 612 - AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( 613 - e => { 613 + const handleRemoveHistoryItem = React.useCallback( 614 + (itemToRemove: string) => { 615 + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) 616 + setSearchHistory(updatedHistory) 617 + AsyncStorage.setItem( 618 + 'searchHistory', 619 + JSON.stringify(updatedHistory), 620 + ).catch(e => { 614 621 logger.error('Failed to update search history', {message: e}) 615 - }, 616 - ) 617 - } 622 + }) 623 + }, 624 + [searchHistory], 625 + ) 618 626 619 627 return ( 620 628 <View style={isWeb ? null : {flex: 1}}> ··· 642 650 /> 643 651 </Pressable> 644 652 )} 645 - 646 - <Pressable 647 - // This only exists only for extra hitslop so don't expose it to the a11y tree. 648 - accessible={false} 649 - focusable={false} 650 - // @ts-ignore web-only 651 - tabIndex={-1} 652 - style={[ 653 - {backgroundColor: pal.colors.backgroundLight}, 654 - styles.headerSearchContainer, 655 - isWeb && { 656 - // @ts-ignore web only 657 - cursor: 'default', 658 - }, 659 - ]} 660 - onPress={() => { 661 - textInput.current?.focus() 662 - }}> 663 - <MagnifyingGlassIcon 664 - style={[pal.icon, styles.headerSearchIcon]} 665 - size={21} 666 - /> 667 - <TextInput 668 - testID="searchTextInput" 669 - ref={textInput} 670 - placeholder={_(msg`Search`)} 671 - placeholderTextColor={pal.colors.textLight} 672 - returnKeyType="search" 673 - value={searchText} 674 - style={[pal.text, styles.headerSearchInput]} 675 - keyboardAppearance={theme.colorScheme} 676 - selectTextOnFocus={isNative} 677 - onFocus={() => { 678 - if (isWeb) { 679 - // Prevent a jump on iPad by ensuring that 680 - // the initial focused render has no result list. 681 - requestAnimationFrame(() => { 682 - setShowAutocomplete(true) 683 - }) 684 - } else { 685 - setShowAutocomplete(true) 686 - if (isIOS) { 687 - // We rely on selectTextOnFocus, but it's broken on iOS: 688 - // https://github.com/facebook/react-native/issues/41988 689 - textInput.current?.setSelection(0, searchText.length) 690 - // We still rely on selectTextOnFocus for it to be instant on Android. 691 - } 692 - } 693 - }} 694 - onChangeText={onChangeText} 695 - onSubmitEditing={onSubmit} 696 - autoFocus={false} 697 - accessibilityRole="search" 698 - accessibilityLabel={_(msg`Search`)} 699 - accessibilityHint="" 700 - autoCorrect={false} 701 - autoComplete="off" 702 - autoCapitalize="none" 703 - /> 704 - {showAutocomplete && searchText.length > 0 && ( 705 - <Pressable 706 - testID="searchTextInputClearBtn" 707 - onPress={onPressClearQuery} 708 - accessibilityRole="button" 709 - accessibilityLabel={_(msg`Clear search query`)} 710 - accessibilityHint="" 711 - hitSlop={HITSLOP_10}> 712 - <FontAwesomeIcon 713 - icon="xmark" 714 - size={16} 715 - style={pal.textLight as FontAwesomeIconStyle} 716 - /> 717 - </Pressable> 718 - )} 719 - </Pressable> 653 + <SearchInputBox 654 + textInput={textInput} 655 + searchText={searchText} 656 + showAutocomplete={showAutocomplete} 657 + setShowAutocomplete={setShowAutocomplete} 658 + onChangeText={onChangeText} 659 + onSubmit={onSubmit} 660 + onPressClearQuery={onPressClearQuery} 661 + /> 720 662 {showAutocomplete && ( 721 663 <View style={[styles.headerCancelBtn]}> 722 664 <Pressable ··· 730 672 </View> 731 673 )} 732 674 </CenteredView> 675 + <View 676 + style={{ 677 + display: showAutocomplete ? 'flex' : 'none', 678 + flex: 1, 679 + }}> 680 + {searchText.length > 0 ? ( 681 + <AutocompleteResults 682 + isAutocompleteFetching={isAutocompleteFetching} 683 + autocompleteData={autocompleteData} 684 + searchText={searchText} 685 + onSubmit={onSubmit} 686 + onResultPress={onAutocompleteResultPress} 687 + /> 688 + ) : ( 689 + <SearchHistory 690 + searchHistory={searchHistory} 691 + onItemClick={handleHistoryItemClick} 692 + onRemoveItemClick={handleRemoveHistoryItem} 693 + /> 694 + )} 695 + </View> 696 + <View 697 + style={{ 698 + display: showAutocomplete ? 'none' : 'flex', 699 + flex: 1, 700 + }}> 701 + <SearchScreenInner query={queryParam} /> 702 + </View> 703 + </View> 704 + ) 705 + } 733 706 734 - {showAutocomplete && searchText.length > 0 ? ( 735 - <> 736 - {(isAutocompleteFetching && !autocompleteData?.length) || 737 - !moderationOpts ? ( 738 - <Loader /> 739 - ) : ( 740 - <ScrollView 741 - style={{height: '100%'}} 742 - // @ts-ignore web only -prf 743 - dataSet={{stableGutters: '1'}} 744 - keyboardShouldPersistTaps="handled" 745 - keyboardDismissMode="on-drag"> 746 - <SearchLinkCard 747 - label={_(msg`Search for "${searchText}"`)} 748 - onPress={isNative ? onSubmit : undefined} 749 - to={ 750 - isNative 751 - ? undefined 752 - : `/search?q=${encodeURIComponent(searchText)}` 753 - } 754 - style={{borderBottomWidth: 1}} 755 - /> 756 - 757 - {queryMaybeHandle ? ( 758 - <SearchLinkCard 759 - label={_(msg`Go to @${queryMaybeHandle}`)} 760 - to={`/profile/${queryMaybeHandle}`} 761 - /> 762 - ) : null} 763 - 764 - {autocompleteData?.map(item => ( 765 - <SearchProfileCard 766 - key={item.did} 767 - profile={item} 768 - moderation={moderateProfile(item, moderationOpts)} 769 - onPress={() => { 770 - if (isWeb) { 771 - setShowAutocomplete(false) 772 - } else { 773 - textInput.current?.blur() 774 - } 775 - }} 776 - /> 777 - ))} 707 + let SearchInputBox = ({ 708 + textInput, 709 + searchText, 710 + showAutocomplete, 711 + setShowAutocomplete, 712 + onChangeText, 713 + onSubmit, 714 + onPressClearQuery, 715 + }: { 716 + textInput: React.RefObject<TextInput> 717 + searchText: string 718 + showAutocomplete: boolean 719 + setShowAutocomplete: (show: boolean) => void 720 + onChangeText: (text: string) => void 721 + onSubmit: () => void 722 + onPressClearQuery: () => void 723 + }): React.ReactNode => { 724 + const pal = usePalette('default') 725 + const {_} = useLingui() 726 + const theme = useTheme() 727 + return ( 728 + <Pressable 729 + // This only exists only for extra hitslop so don't expose it to the a11y tree. 730 + accessible={false} 731 + focusable={false} 732 + // @ts-ignore web-only 733 + tabIndex={-1} 734 + style={[ 735 + {backgroundColor: pal.colors.backgroundLight}, 736 + styles.headerSearchContainer, 737 + isWeb && { 738 + // @ts-ignore web only 739 + cursor: 'default', 740 + }, 741 + ]} 742 + onPress={() => { 743 + textInput.current?.focus() 744 + }}> 745 + <MagnifyingGlassIcon 746 + style={[pal.icon, styles.headerSearchIcon]} 747 + size={21} 748 + /> 749 + <TextInput 750 + testID="searchTextInput" 751 + ref={textInput} 752 + placeholder={_(msg`Search`)} 753 + placeholderTextColor={pal.colors.textLight} 754 + returnKeyType="search" 755 + value={searchText} 756 + style={[pal.text, styles.headerSearchInput]} 757 + keyboardAppearance={theme.colorScheme} 758 + selectTextOnFocus={isNative} 759 + onFocus={() => { 760 + if (isWeb) { 761 + // Prevent a jump on iPad by ensuring that 762 + // the initial focused render has no result list. 763 + requestAnimationFrame(() => { 764 + setShowAutocomplete(true) 765 + }) 766 + } else { 767 + setShowAutocomplete(true) 768 + if (isIOS) { 769 + // We rely on selectTextOnFocus, but it's broken on iOS: 770 + // https://github.com/facebook/react-native/issues/41988 771 + textInput.current?.setSelection(0, searchText.length) 772 + // We still rely on selectTextOnFocus for it to be instant on Android. 773 + } 774 + } 775 + }} 776 + onChangeText={onChangeText} 777 + onSubmitEditing={onSubmit} 778 + autoFocus={false} 779 + accessibilityRole="search" 780 + accessibilityLabel={_(msg`Search`)} 781 + accessibilityHint="" 782 + autoCorrect={false} 783 + autoComplete="off" 784 + autoCapitalize="none" 785 + /> 786 + {showAutocomplete && searchText.length > 0 && ( 787 + <Pressable 788 + testID="searchTextInputClearBtn" 789 + onPress={onPressClearQuery} 790 + accessibilityRole="button" 791 + accessibilityLabel={_(msg`Clear search query`)} 792 + accessibilityHint="" 793 + hitSlop={HITSLOP_10}> 794 + <FontAwesomeIcon 795 + icon="xmark" 796 + size={16} 797 + style={pal.textLight as FontAwesomeIconStyle} 798 + /> 799 + </Pressable> 800 + )} 801 + </Pressable> 802 + ) 803 + } 804 + SearchInputBox = React.memo(SearchInputBox) 778 805 779 - <View style={{height: 200}} /> 780 - </ScrollView> 781 - )} 782 - </> 783 - ) : !queryParam && showAutocomplete ? ( 784 - <CenteredView 785 - sideBorders={isTabletOrDesktop} 806 + let AutocompleteResults = ({ 807 + isAutocompleteFetching, 808 + autocompleteData, 809 + searchText, 810 + onSubmit, 811 + onResultPress, 812 + }: { 813 + isAutocompleteFetching: boolean 814 + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined 815 + searchText: string 816 + onSubmit: () => void 817 + onResultPress: () => void 818 + }): React.ReactNode => { 819 + const moderationOpts = useModerationOpts() 820 + const {_} = useLingui() 821 + return ( 822 + <> 823 + {(isAutocompleteFetching && !autocompleteData?.length) || 824 + !moderationOpts ? ( 825 + <Loader /> 826 + ) : ( 827 + <ScrollView 828 + style={{height: '100%'}} 786 829 // @ts-ignore web only -prf 787 - style={{ 788 - height: isWeb ? '100vh' : undefined, 789 - }}> 790 - <View style={styles.searchHistoryContainer}> 791 - {searchHistory.length > 0 && ( 792 - <View style={styles.searchHistoryContent}> 793 - <Text style={[pal.text, styles.searchHistoryTitle]}> 794 - <Trans>Recent Searches</Trans> 795 - </Text> 796 - {searchHistory.map((historyItem, index) => ( 797 - <View 798 - key={index} 799 - style={[ 800 - a.flex_row, 801 - a.mt_md, 802 - a.justify_center, 803 - a.justify_between, 804 - ]}> 805 - <Pressable 806 - accessibilityRole="button" 807 - onPress={() => handleHistoryItemClick(historyItem)} 808 - style={[a.flex_1, a.py_sm]}> 809 - <Text style={pal.text}>{historyItem}</Text> 810 - </Pressable> 811 - <Pressable 812 - accessibilityRole="button" 813 - onPress={() => handleRemoveHistoryItem(historyItem)} 814 - style={[a.px_md, a.py_xs, a.justify_center]}> 815 - <FontAwesomeIcon 816 - icon="xmark" 817 - size={16} 818 - style={pal.textLight as FontAwesomeIconStyle} 819 - /> 820 - </Pressable> 821 - </View> 822 - ))} 830 + dataSet={{stableGutters: '1'}} 831 + keyboardShouldPersistTaps="handled" 832 + keyboardDismissMode="on-drag"> 833 + <SearchLinkCard 834 + label={_(msg`Search for "${searchText}"`)} 835 + onPress={isNative ? onSubmit : undefined} 836 + to={ 837 + isNative 838 + ? undefined 839 + : `/search?q=${encodeURIComponent(searchText)}` 840 + } 841 + style={{borderBottomWidth: 1}} 842 + /> 843 + {autocompleteData?.map(item => ( 844 + <SearchProfileCard 845 + key={item.did} 846 + profile={item} 847 + moderation={moderateProfile(item, moderationOpts)} 848 + onPress={onResultPress} 849 + /> 850 + ))} 851 + <View style={{height: 200}} /> 852 + </ScrollView> 853 + )} 854 + </> 855 + ) 856 + } 857 + AutocompleteResults = React.memo(AutocompleteResults) 858 + 859 + function SearchHistory({ 860 + searchHistory, 861 + onItemClick, 862 + onRemoveItemClick, 863 + }: { 864 + searchHistory: string[] 865 + onItemClick: (item: string) => void 866 + onRemoveItemClick: (item: string) => void 867 + }) { 868 + const {isTabletOrDesktop} = useWebMediaQueries() 869 + const pal = usePalette('default') 870 + return ( 871 + <CenteredView 872 + sideBorders={isTabletOrDesktop} 873 + // @ts-ignore web only -prf 874 + style={{ 875 + height: isWeb ? '100vh' : undefined, 876 + }}> 877 + <View style={styles.searchHistoryContainer}> 878 + {searchHistory.length > 0 && ( 879 + <View style={styles.searchHistoryContent}> 880 + <Text style={[pal.text, styles.searchHistoryTitle]}> 881 + <Trans>Recent Searches</Trans> 882 + </Text> 883 + {searchHistory.map((historyItem, index) => ( 884 + <View 885 + key={index} 886 + style={[ 887 + a.flex_row, 888 + a.mt_md, 889 + a.justify_center, 890 + a.justify_between, 891 + ]}> 892 + <Pressable 893 + accessibilityRole="button" 894 + onPress={() => onItemClick(historyItem)} 895 + hitSlop={HITSLOP_10} 896 + style={[a.flex_1, a.py_sm]}> 897 + <Text style={pal.text}>{historyItem}</Text> 898 + </Pressable> 899 + <Pressable 900 + accessibilityRole="button" 901 + onPress={() => onRemoveItemClick(historyItem)} 902 + hitSlop={HITSLOP_10} 903 + style={[a.px_md, a.py_xs, a.justify_center]}> 904 + <FontAwesomeIcon 905 + icon="xmark" 906 + size={16} 907 + style={pal.textLight as FontAwesomeIconStyle} 908 + /> 909 + </Pressable> 823 910 </View> 824 - )} 911 + ))} 825 912 </View> 826 - </CenteredView> 827 - ) : ( 828 - <SearchScreenInner query={queryParam} /> 829 - )} 830 - </View> 913 + )} 914 + </View> 915 + </CenteredView> 831 916 ) 832 917 } 833 918
+9 -21
src/view/shell/desktop/Search.tsx
··· 31 31 import {UserAvatar} from '#/view/com/util/UserAvatar' 32 32 import {Text} from 'view/com/util/text/Text' 33 33 34 - export const MATCH_HANDLE = 35 - /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ 36 - 37 - export function SearchLinkCard({ 34 + let SearchLinkCard = ({ 38 35 label, 39 36 to, 40 37 onPress, ··· 44 41 to?: string 45 42 onPress?: () => void 46 43 style?: ViewStyle 47 - }) { 44 + }): React.ReactNode => { 48 45 const pal = usePalette('default') 49 46 50 47 const inner = ( ··· 82 79 </Link> 83 80 ) 84 81 } 82 + SearchLinkCard = React.memo(SearchLinkCard) 83 + export {SearchLinkCard} 85 84 86 - export function SearchProfileCard({ 85 + let SearchProfileCard = ({ 87 86 profile, 88 87 moderation, 89 88 onPress: onPressInner, ··· 91 90 profile: AppBskyActorDefs.ProfileViewBasic 92 91 moderation: ModerationDecision 93 92 onPress: () => void 94 - }) { 93 + }): React.ReactNode => { 95 94 const pal = usePalette('default') 96 95 const queryClient = useQueryClient() 97 96 ··· 144 143 </Link> 145 144 ) 146 145 } 146 + SearchProfileCard = React.memo(SearchProfileCard) 147 + export {SearchProfileCard} 147 148 148 149 export function DesktopSearch() { 149 150 const {_} = useLingui() ··· 179 180 setIsActive(false) 180 181 }, []) 181 182 182 - const queryMaybeHandle = React.useMemo(() => { 183 - const match = MATCH_HANDLE.exec(query) 184 - return match && match[1] 185 - }, [query]) 186 - 187 183 return ( 188 184 <View style={[styles.container, pal.view]}> 189 185 <View ··· 239 235 label={_(msg`Search for "${query}"`)} 240 236 to={`/search?q=${encodeURIComponent(query)}`} 241 237 style={ 242 - queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 238 + (autocompleteData?.length ?? 0) > 0 243 239 ? {borderBottomWidth: 1} 244 240 : undefined 245 241 } 246 242 /> 247 - 248 - {queryMaybeHandle ? ( 249 - <SearchLinkCard 250 - label={_(msg`Go to @${queryMaybeHandle}`)} 251 - to={`/profile/${queryMaybeHandle}`} 252 - /> 253 - ) : null} 254 - 255 243 {autocompleteData?.map(item => ( 256 244 <SearchProfileCard 257 245 key={item.did}