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 hitslopszio/stable
parent
3c2d73909b
commit
5d715ae1d0
|
@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
|||
import {List} from '#/view/com/util/List'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||
import {
|
||||
MATCH_HANDLE,
|
||||
SearchLinkCard,
|
||||
SearchProfileCard,
|
||||
} from '#/view/shell/desktop/Search'
|
||||
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
|
@ -156,7 +152,7 @@ function useSuggestedFollows(): [
|
|||
return [items, onEndReached]
|
||||
}
|
||||
|
||||
function SearchScreenSuggestedFollows() {
|
||||
let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const [suggestions, onEndReached] = useSuggestedFollows()
|
||||
|
||||
|
@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() {
|
|||
</CenteredView>
|
||||
)
|
||||
}
|
||||
SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
|
||||
|
||||
type SearchResultSlice =
|
||||
| {
|
||||
|
@ -192,7 +189,7 @@ type SearchResultSlice =
|
|||
key: string
|
||||
}
|
||||
|
||||
function SearchScreenPostResults({
|
||||
let SearchScreenPostResults = ({
|
||||
query,
|
||||
sort,
|
||||
active,
|
||||
|
@ -200,7 +197,7 @@ function SearchScreenPostResults({
|
|||
query: string
|
||||
sort?: 'top' | 'latest'
|
||||
active: boolean
|
||||
}) {
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const [isPTR, setIsPTR] = React.useState(false)
|
||||
|
@ -298,14 +295,15 @@ function SearchScreenPostResults({
|
|||
</>
|
||||
)
|
||||
}
|
||||
SearchScreenPostResults = React.memo(SearchScreenPostResults)
|
||||
|
||||
function SearchScreenUserResults({
|
||||
let SearchScreenUserResults = ({
|
||||
query,
|
||||
active,
|
||||
}: {
|
||||
query: string
|
||||
active: boolean
|
||||
}) {
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: results, isFetched} = useActorSearch({
|
||||
|
@ -334,8 +332,9 @@ function SearchScreenUserResults({
|
|||
<Loader />
|
||||
)
|
||||
}
|
||||
SearchScreenUserResults = React.memo(SearchScreenUserResults)
|
||||
|
||||
export function SearchScreenInner({query}: {query?: string}) {
|
||||
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
|
@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) {
|
|||
</CenteredView>
|
||||
)
|
||||
}
|
||||
SearchScreenInner = React.memo(SearchScreenInner)
|
||||
|
||||
export function SearchScreen(
|
||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||
) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const theme = useTheme()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
|
@ -584,21 +582,27 @@ export function SearchScreen(
|
|||
navigateToItem(searchText)
|
||||
}, [navigateToItem, searchText])
|
||||
|
||||
const handleHistoryItemClick = (item: string) => {
|
||||
const onAutocompleteResultPress = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
setShowAutocomplete(false)
|
||||
} else {
|
||||
textInput.current?.blur()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleHistoryItemClick = React.useCallback(
|
||||
(item: string) => {
|
||||
setSearchText(item)
|
||||
navigateToItem(item)
|
||||
}
|
||||
},
|
||||
[navigateToItem],
|
||||
)
|
||||
|
||||
const onSoftReset = React.useCallback(() => {
|
||||
scrollToTopWeb()
|
||||
onPressCancelSearch()
|
||||
}, [onPressCancelSearch])
|
||||
|
||||
const queryMaybeHandle = React.useMemo(() => {
|
||||
const match = MATCH_HANDLE.exec(queryParam)
|
||||
return match && match[1]
|
||||
}, [queryParam])
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
|
@ -606,15 +610,19 @@ export function SearchScreen(
|
|||
}, [onSoftReset, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const handleRemoveHistoryItem = (itemToRemove: string) => {
|
||||
const handleRemoveHistoryItem = React.useCallback(
|
||||
(itemToRemove: string) => {
|
||||
const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
|
||||
setSearchHistory(updatedHistory)
|
||||
AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch(
|
||||
e => {
|
||||
AsyncStorage.setItem(
|
||||
'searchHistory',
|
||||
JSON.stringify(updatedHistory),
|
||||
).catch(e => {
|
||||
logger.error('Failed to update search history', {message: e})
|
||||
})
|
||||
},
|
||||
[searchHistory],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={isWeb ? null : {flex: 1}}>
|
||||
|
@ -642,7 +650,81 @@ export function SearchScreen(
|
|||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
<SearchInputBox
|
||||
textInput={textInput}
|
||||
searchText={searchText}
|
||||
showAutocomplete={showAutocomplete}
|
||||
setShowAutocomplete={setShowAutocomplete}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
onPressClearQuery={onPressClearQuery}
|
||||
/>
|
||||
{showAutocomplete && (
|
||||
<View style={[styles.headerCancelBtn]}>
|
||||
<Pressable
|
||||
onPress={onPressCancelSearch}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text style={pal.text}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</CenteredView>
|
||||
<View
|
||||
style={{
|
||||
display: showAutocomplete ? 'flex' : 'none',
|
||||
flex: 1,
|
||||
}}>
|
||||
{searchText.length > 0 ? (
|
||||
<AutocompleteResults
|
||||
isAutocompleteFetching={isAutocompleteFetching}
|
||||
autocompleteData={autocompleteData}
|
||||
searchText={searchText}
|
||||
onSubmit={onSubmit}
|
||||
onResultPress={onAutocompleteResultPress}
|
||||
/>
|
||||
) : (
|
||||
<SearchHistory
|
||||
searchHistory={searchHistory}
|
||||
onItemClick={handleHistoryItemClick}
|
||||
onRemoveItemClick={handleRemoveHistoryItem}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
display: showAutocomplete ? 'none' : 'flex',
|
||||
flex: 1,
|
||||
}}>
|
||||
<SearchScreenInner query={queryParam} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
let SearchInputBox = ({
|
||||
textInput,
|
||||
searchText,
|
||||
showAutocomplete,
|
||||
setShowAutocomplete,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
onPressClearQuery,
|
||||
}: {
|
||||
textInput: React.RefObject<TextInput>
|
||||
searchText: string
|
||||
showAutocomplete: boolean
|
||||
setShowAutocomplete: (show: boolean) => void
|
||||
onChangeText: (text: string) => void
|
||||
onSubmit: () => void
|
||||
onPressClearQuery: () => void
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Pressable
|
||||
// This only exists only for extra hitslop so don't expose it to the a11y tree.
|
||||
accessible={false}
|
||||
|
@ -717,21 +799,26 @@ export function SearchScreen(
|
|||
</Pressable>
|
||||
)}
|
||||
</Pressable>
|
||||
{showAutocomplete && (
|
||||
<View style={[styles.headerCancelBtn]}>
|
||||
<Pressable
|
||||
onPress={onPressCancelSearch}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text style={pal.text}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
SearchInputBox = React.memo(SearchInputBox)
|
||||
|
||||
{showAutocomplete && searchText.length > 0 ? (
|
||||
let AutocompleteResults = ({
|
||||
isAutocompleteFetching,
|
||||
autocompleteData,
|
||||
searchText,
|
||||
onSubmit,
|
||||
onResultPress,
|
||||
}: {
|
||||
isAutocompleteFetching: boolean
|
||||
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
|
||||
searchText: string
|
||||
onSubmit: () => void
|
||||
onResultPress: () => void
|
||||
}): React.ReactNode => {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<>
|
||||
{(isAutocompleteFetching && !autocompleteData?.length) ||
|
||||
!moderationOpts ? (
|
||||
|
@ -753,34 +840,34 @@ export function SearchScreen(
|
|||
}
|
||||
style={{borderBottomWidth: 1}}
|
||||
/>
|
||||
|
||||
{queryMaybeHandle ? (
|
||||
<SearchLinkCard
|
||||
label={_(msg`Go to @${queryMaybeHandle}`)}
|
||||
to={`/profile/${queryMaybeHandle}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{autocompleteData?.map(item => (
|
||||
<SearchProfileCard
|
||||
key={item.did}
|
||||
profile={item}
|
||||
moderation={moderateProfile(item, moderationOpts)}
|
||||
onPress={() => {
|
||||
if (isWeb) {
|
||||
setShowAutocomplete(false)
|
||||
} else {
|
||||
textInput.current?.blur()
|
||||
}
|
||||
}}
|
||||
onPress={onResultPress}
|
||||
/>
|
||||
))}
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</>
|
||||
) : !queryParam && showAutocomplete ? (
|
||||
)
|
||||
}
|
||||
AutocompleteResults = React.memo(AutocompleteResults)
|
||||
|
||||
function SearchHistory({
|
||||
searchHistory,
|
||||
onItemClick,
|
||||
onRemoveItemClick,
|
||||
}: {
|
||||
searchHistory: string[]
|
||||
onItemClick: (item: string) => void
|
||||
onRemoveItemClick: (item: string) => void
|
||||
}) {
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<CenteredView
|
||||
sideBorders={isTabletOrDesktop}
|
||||
// @ts-ignore web only -prf
|
||||
|
@ -804,13 +891,15 @@ export function SearchScreen(
|
|||
]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => handleHistoryItemClick(historyItem)}
|
||||
onPress={() => onItemClick(historyItem)}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[a.flex_1, a.py_sm]}>
|
||||
<Text style={pal.text}>{historyItem}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => handleRemoveHistoryItem(historyItem)}
|
||||
onPress={() => onRemoveItemClick(historyItem)}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[a.px_md, a.py_xs, a.justify_center]}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
|
@ -824,10 +913,6 @@ export function SearchScreen(
|
|||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
) : (
|
||||
<SearchScreenInner query={queryParam} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link'
|
|||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
|
||||
export const MATCH_HANDLE =
|
||||
/@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
|
||||
|
||||
export function SearchLinkCard({
|
||||
let SearchLinkCard = ({
|
||||
label,
|
||||
to,
|
||||
onPress,
|
||||
|
@ -44,7 +41,7 @@ export function SearchLinkCard({
|
|||
to?: string
|
||||
onPress?: () => void
|
||||
style?: ViewStyle
|
||||
}) {
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
const inner = (
|
||||
|
@ -82,8 +79,10 @@ export function SearchLinkCard({
|
|||
</Link>
|
||||
)
|
||||
}
|
||||
SearchLinkCard = React.memo(SearchLinkCard)
|
||||
export {SearchLinkCard}
|
||||
|
||||
export function SearchProfileCard({
|
||||
let SearchProfileCard = ({
|
||||
profile,
|
||||
moderation,
|
||||
onPress: onPressInner,
|
||||
|
@ -91,7 +90,7 @@ export function SearchProfileCard({
|
|||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ModerationDecision
|
||||
onPress: () => void
|
||||
}) {
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
@ -144,6 +143,8 @@ export function SearchProfileCard({
|
|||
</Link>
|
||||
)
|
||||
}
|
||||
SearchProfileCard = React.memo(SearchProfileCard)
|
||||
export {SearchProfileCard}
|
||||
|
||||
export function DesktopSearch() {
|
||||
const {_} = useLingui()
|
||||
|
@ -179,11 +180,6 @@ export function DesktopSearch() {
|
|||
setIsActive(false)
|
||||
}, [])
|
||||
|
||||
const queryMaybeHandle = React.useMemo(() => {
|
||||
const match = MATCH_HANDLE.exec(query)
|
||||
return match && match[1]
|
||||
}, [query])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
<View
|
||||
|
@ -239,19 +235,11 @@ export function DesktopSearch() {
|
|||
label={_(msg`Search for "${query}"`)}
|
||||
to={`/search?q=${encodeURIComponent(query)}`}
|
||||
style={
|
||||
queryMaybeHandle || (autocompleteData?.length ?? 0) > 0
|
||||
(autocompleteData?.length ?? 0) > 0
|
||||
? {borderBottomWidth: 1}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{queryMaybeHandle ? (
|
||||
<SearchLinkCard
|
||||
label={_(msg`Go to @${queryMaybeHandle}`)}
|
||||
to={`/profile/${queryMaybeHandle}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{autocompleteData?.map(item => (
|
||||
<SearchProfileCard
|
||||
key={item.did}
|
||||
|
|
Loading…
Reference in New Issue