Fix infinite loading spinner when changing search terms (#9950)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-02-27 03:42:31 -08:00
committed by GitHub
parent 6cb88ce1f3
commit 8a1f8997fe
3 changed files with 83 additions and 92 deletions
+41 -45
View File
@@ -1,9 +1,7 @@
import {memo, useCallback, useMemo, useState} from 'react' import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native' import {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
@@ -27,6 +25,7 @@ import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
import {SearchError} from '#/components/SearchError' import {SearchError} from '#/components/SearchError'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
let SearchResults = ({ let SearchResults = ({
query, query,
@@ -43,14 +42,14 @@ let SearchResults = ({
headerHeight: number headerHeight: number
initialPage?: number initialPage?: number
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const sections = useMemo(() => { const sections = useMemo(() => {
if (!queryWithParams) return [] if (!queryWithParams) return []
const noParams = queryWithParams === query const noParams = queryWithParams === query
return [ return [
{ {
title: _(msg`Top`), title: l`Top`,
component: ( component: (
<SearchScreenPostResults <SearchScreenPostResults
query={queryWithParams} query={queryWithParams}
@@ -60,7 +59,7 @@ let SearchResults = ({
), ),
}, },
{ {
title: _(msg`Latest`), title: l`Latest`,
component: ( component: (
<SearchScreenPostResults <SearchScreenPostResults
query={queryWithParams} query={queryWithParams}
@@ -70,13 +69,13 @@ let SearchResults = ({
), ),
}, },
noParams && { noParams && {
title: _(msg`People`), title: l`People`,
component: ( component: (
<SearchScreenUserResults query={query} active={activeTab === 2} /> <SearchScreenUserResults query={query} active={activeTab === 2} />
), ),
}, },
noParams && { noParams && {
title: _(msg`Feeds`), title: l`Feeds`,
component: ( component: (
<SearchScreenFeedsResults query={query} active={activeTab === 3} /> <SearchScreenFeedsResults query={query} active={activeTab === 3} />
), ),
@@ -85,7 +84,10 @@ let SearchResults = ({
title: string title: string
component: React.ReactNode component: React.ReactNode
}[] }[]
}, [_, query, queryWithParams, activeTab]) }, [l, query, queryWithParams, activeTab])
// There may be fewer tabs after changing the search options.
const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage
return ( return (
<Pager <Pager
@@ -95,7 +97,7 @@ let SearchResults = ({
<TabBar items={sections.map(section => section.title)} {...props} /> <TabBar items={sections.map(section => section.title)} {...props} />
</Layout.Center> </Layout.Center>
)} )}
initialPage={initialPage}> initialPage={selectedPage}>
{sections.map((section, i) => ( {sections.map((section, i) => (
<View key={i}>{section.component}</View> <View key={i}>{section.component}</View>
))} ))}
@@ -161,15 +163,15 @@ function EmptyState({
function NoResultsText({query}: {query: string}) { function NoResultsText({query}: {query: string}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<> <>
<Text style={[a.text_lg, t.atoms.text_contrast_high]}> <Text style={[a.text_lg, t.atoms.text_contrast_high]}>
<Trans> <Trans>
No results found for " No results found for
<Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text> <Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text>
". .
</Trans> </Trans>
</Text> </Text>
{'\n\n'} {'\n\n'}
@@ -177,12 +179,10 @@ function NoResultsText({query}: {query: string}) {
<Trans context="english-only-resource"> <Trans context="english-only-resource">
Try a different search term, or{' '} Try a different search term, or{' '}
<InlineLinkText <InlineLinkText
label={_( label={l({
msg({ message: 'read about how to use search filters',
message: 'read about how to use search filters', context: 'english-only-resource',
context: 'english-only-resource', })}
}),
)}
to={urls.website.blog.searchTipsAndTricks} to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}> style={[a.text_md, a.leading_snug]}>
read about how to use search filters read about how to use search filters
@@ -214,7 +214,7 @@ let SearchScreenPostResults = ({
sort?: 'top' | 'latest' sort?: 'top' | 'latest'
active: boolean active: boolean
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false) const [isPTR, setIsPTR] = useState(false)
const trackPostView = usePostViewTracking('SearchResults') const trackPostView = usePostViewTracking('SearchResults')
@@ -242,7 +242,7 @@ let SearchScreenPostResults = ({
}, [setIsPTR, refetch]) }, [setIsPTR, refetch])
const onEndReached = useCallback(() => { const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return if (isFetching || !hasNextPage || error) return
fetchNextPage() void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage]) }, [isFetching, error, hasNextPage, fetchNextPage])
const posts = useMemo(() => { const posts = useMemo(() => {
@@ -289,19 +289,15 @@ let SearchScreenPostResults = ({
if (!hasSession) { if (!hasSession) {
return ( return (
<SearchError <SearchError title={l`Search is currently unavailable when logged out`}>
title={_(msg`Search is currently unavailable when logged out`)}>
<Text style={[a.text_md, a.text_center, a.leading_snug]}> <Text style={[a.text_md, a.text_center, a.leading_snug]}>
<Trans> <Trans>
<InlineLinkText <InlineLinkText label={l`Sign in`} to={'#'} onPress={showSignIn}>
label={_(msg`Sign in`)}
to={'#'}
onPress={showSignIn}>
Sign in Sign in
</InlineLinkText> </InlineLinkText>
<Text style={t.atoms.text_contrast_medium}> or </Text> <Text style={t.atoms.text_contrast_medium}> or </Text>
<InlineLinkText <InlineLinkText
label={_(msg`Create an account`)} label={l`Create an account`}
to={'#'} to={'#'}
onPress={showCreateAccount}> onPress={showCreateAccount}>
create an account create an account
@@ -319,9 +315,7 @@ let SearchScreenPostResults = ({
return error ? ( return error ? (
<EmptyState <EmptyState
messageText={_( messageText={l`We're sorry, but your search could not be completed. Please try again in a few minutes.`}
msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
)}
error={cleanError(error)} error={cleanError(error)}
/> />
) : ( ) : (
@@ -331,18 +325,20 @@ let SearchScreenPostResults = ({
{posts.length ? ( {posts.length ? (
<List <List
data={items} data={items}
renderItem={({item}) => { renderItem={({item}: {item: SearchResultSlice}) => {
if (item.type === 'post') { if (item.type === 'post') {
return <Post post={item.post} /> return <Post post={item.post} />
} else { } else {
return null return null
} }
}} }}
keyExtractor={item => item.key} keyExtractor={(item: SearchResultSlice) => item.key}
refreshing={isPTR} refreshing={isPTR}
onRefresh={onPullToRefresh} onRefresh={() => {
void onPullToRefresh()
}}
onEndReached={onEndReached} onEndReached={onEndReached}
onItemSeen={item => { onItemSeen={(item: SearchResultSlice) => {
if (item.type === 'post') { if (item.type === 'post') {
trackPostView(item.post) trackPostView(item.post)
} }
@@ -374,7 +370,7 @@ let SearchScreenUserResults = ({
query: string query: string
active: boolean active: boolean
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const {hasSession} = useSession() const {hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false) const [isPTR, setIsPTR] = useState(false)
@@ -400,7 +396,7 @@ let SearchScreenUserResults = ({
const onEndReached = useCallback(() => { const onEndReached = useCallback(() => {
if (!hasSession) return if (!hasSession) return
if (isFetching || !hasNextPage || error) return if (isFetching || !hasNextPage || error) return
fetchNextPage() void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage, hasSession]) }, [isFetching, error, hasNextPage, fetchNextPage, hasSession])
const profiles = useMemo(() => { const profiles = useMemo(() => {
@@ -410,9 +406,7 @@ let SearchScreenUserResults = ({
if (error) { if (error) {
return ( return (
<EmptyState <EmptyState
messageText={_( messageText={l`Were sorry, but your search could not be completed. Please try again in a few minutes.`}
msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
)}
error={error.toString()} error={error.toString()}
/> />
) )
@@ -423,10 +417,12 @@ let SearchScreenUserResults = ({
{profiles.length ? ( {profiles.length ? (
<List <List
data={profiles} data={profiles}
renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} />} renderItem={({item}: {item: bsky.profile.AnyProfileView}) => (
keyExtractor={item => item.did} <ProfileCardWithFollowBtn profile={item} />
)}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
refreshing={isPTR} refreshing={isPTR}
onRefresh={onPullToRefresh} onRefresh={() => void onPullToRefresh()}
onEndReached={onEndReached} onEndReached={onEndReached}
desktopFixedHeight desktopFixedHeight
ListFooterComponent={ ListFooterComponent={
@@ -465,7 +461,7 @@ let SearchScreenFeedsResults = ({
{results.length ? ( {results.length ? (
<List <List
data={results} data={results}
renderItem={({item}) => ( renderItem={({item}: {item: AppBskyFeedDefs.GeneratorView}) => (
<View <View
style={[ style={[
a.border_t, a.border_t,
@@ -476,7 +472,7 @@ let SearchScreenFeedsResults = ({
<FeedCard.Default view={item} /> <FeedCard.Default view={item} />
</View> </View>
)} )}
keyExtractor={item => item.uri} keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri}
desktopFixedHeight desktopFixedHeight
ListFooterComponent={<ListFooter />} ListFooterComponent={<ListFooter />}
/> />
+40 -45
View File
@@ -12,9 +12,7 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -49,6 +47,23 @@ import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
import {Explore} from './Explore' import {Explore} from './Explore'
import {SearchResults} from './SearchResults' import {SearchResults} from './SearchResults'
type TabParam = 'user' | 'profile' | 'feed' | 'latest'
// Map tab parameter to tab index
function getTabIndex(tabParam?: TabParam) {
switch (tabParam) {
case 'feed':
return 3 // Feeds tab
case 'user':
case 'profile':
return 2 // People tab
case 'latest':
return 1 // Latest tab
default:
return 0 // Top tab
}
}
export function SearchScreenShell({ export function SearchScreenShell({
queryParam, queryParam,
testID, testID,
@@ -69,11 +84,15 @@ export function SearchScreenShell({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const route = useRoute() const route = useRoute()
const textInput = useRef<TextInput>(null) const textInput = useRef<TextInput>(null)
const {_} = useLingui() const {t: l} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Get tab parameter from route params
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
// Query terms // Query terms
const [searchText, setSearchText] = useState<string>(queryParam) const [searchText, setSearchText] = useState<string>(queryParam)
const {data: autocompleteData, isFetching: isAutocompleteFetching} = const {data: autocompleteData, isFetching: isAutocompleteFetching} =
@@ -96,7 +115,7 @@ export function SearchScreenShell({
}) })
const updateSearchHistory = useCallback( const updateSearchHistory = useCallback(
async (item: string) => { (item: string) => {
if (!item) return if (!item) return
const newSearchHistory = [ const newSearchHistory = [
item, item,
@@ -108,7 +127,7 @@ export function SearchScreenShell({
) )
const updateProfileHistory = useCallback( const updateProfileHistory = useCallback(
async (item: bsky.profile.AnyProfileView) => { (item: bsky.profile.AnyProfileView) => {
const newAccountHistory = [ const newAccountHistory = [
item.did, item.did,
...accountHistory.filter(p => p !== item.did), ...accountHistory.filter(p => p !== item.did),
@@ -119,13 +138,13 @@ export function SearchScreenShell({
) )
const deleteSearchHistoryItem = useCallback( const deleteSearchHistoryItem = useCallback(
async (item: string) => { (item: string) => {
setTermHistory(termHistory.filter(search => search !== item)) setTermHistory(termHistory.filter(search => search !== item))
}, },
[termHistory, setTermHistory], [termHistory, setTermHistory],
) )
const deleteProfileHistoryItem = useCallback( const deleteProfileHistoryItem = useCallback(
async (item: bsky.profile.AnyProfileView) => { (item: bsky.profile.AnyProfileView) => {
setAccountHistory(accountHistory.filter(p => p !== item.did)) setAccountHistory(accountHistory.filter(p => p !== item.did))
}, },
[accountHistory, setAccountHistory], [accountHistory, setAccountHistory],
@@ -162,7 +181,7 @@ export function SearchScreenShell({
textInput.current?.focus() textInput.current?.focus()
}, []) }, [])
const onChangeText = useCallback(async (text: string) => { const onChangeText = useCallback((text: string) => {
scrollToTopWeb() scrollToTopWeb()
setSearchText(text) setSearchText(text)
}, []) }, [])
@@ -277,7 +296,7 @@ export function SearchScreenShell({
}, [setShowAutocomplete]) }, [setShowAutocomplete])
const focusSearchInput = useCallback( const focusSearchInput = useCallback(
(tab?: 'user' | 'profile' | 'feed') => { (tab?: TabParam) => {
textInput.current?.focus() textInput.current?.focus()
// If a tab is specified, set the tab parameter // If a tab is specified, set the tab parameter
@@ -350,15 +369,14 @@ export function SearchScreenShell({
onClearText={onPressClearQuery} onClearText={onPressClearQuery}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
placeholder={ placeholder={
inputPlaceholder ?? inputPlaceholder ?? l`Search for posts, users, or feeds`
_(msg`Search for posts, users, or feeds`)
} }
hitSlop={{...HITSLOP_20, top: 0}} hitSlop={{...HITSLOP_20, top: 0}}
/> />
</View> </View>
{showAutocomplete && ( {showAutocomplete && (
<Button <Button
label={_(msg`Cancel search`)} label={l`Cancel search`}
size="large" size="large"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
@@ -423,6 +441,9 @@ export function SearchScreenShell({
flex: 1, flex: 1,
}}> }}>
<SearchScreenInner <SearchScreenInner
key={params.lang}
activeTab={activeTab}
setActiveTab={setActiveTab}
query={query} query={query}
queryWithParams={queryWithParams} queryWithParams={queryWithParams}
headerHeight={headerHeight} headerHeight={headerHeight}
@@ -434,57 +455,31 @@ export function SearchScreenShell({
} }
let SearchScreenInner = ({ let SearchScreenInner = ({
activeTab,
setActiveTab,
query, query,
queryWithParams, queryWithParams,
headerHeight, headerHeight,
focusSearchInput, focusSearchInput,
}: { }: {
activeTab: number
setActiveTab: React.Dispatch<React.SetStateAction<number>>
query: string query: string
queryWithParams: string queryWithParams: string
headerHeight: number headerHeight: number
focusSearchInput: (tab?: 'user' | 'profile' | 'feed') => void focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {hasSession} = useSession() const {hasSession} = useSession()
const {gtTablet} = useBreakpoints() const {gtTablet} = useBreakpoints()
const route = useRoute()
// Get tab parameter from route params
const tabParam = (
route.params as {q?: string; tab?: 'user' | 'profile' | 'feed'}
)?.tab
// Map tab parameter to tab index
const getInitialTabIndex = useCallback(() => {
if (!tabParam) return 0
switch (tabParam) {
case 'user':
case 'profile':
return 2 // People tab
case 'feed':
return 3 // Feeds tab
default:
return 0
}
}, [tabParam])
const [activeTab, setActiveTab] = useState(getInitialTabIndex())
// Update activeTab when tabParam changes
useLayoutEffect(() => {
const newTabIndex = getInitialTabIndex()
if (newTabIndex !== activeTab) {
setActiveTab(newTabIndex)
}
}, [tabParam, activeTab, getInitialTabIndex])
const onPageSelected = useCallback( const onPageSelected = useCallback(
(index: number) => { (index: number) => {
setMinimalShellMode(false) setMinimalShellMode(false)
setActiveTab(index) setActiveTab(index)
}, },
[setMinimalShellMode], [setActiveTab, setMinimalShellMode],
) )
return queryWithParams ? ( return queryWithParams ? (
+2 -2
View File
@@ -1,9 +1,9 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import type * as bsky from '#/types/bsky'
export function ProfileCardWithFollowBtn({ export function ProfileCardWithFollowBtn({
profile, profile,
@@ -12,7 +12,7 @@ export function ProfileCardWithFollowBtn({
position, position,
contextProfileDid, contextProfileDid,
}: { }: {
profile: AppBskyActorDefs.ProfileView profile: bsky.profile.AnyProfileView
noBorder?: boolean noBorder?: boolean
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
position?: number position?: number