Bluesky app fork with some witchin' additions 💫

Search lists cleanup, pagninate actor search (#9009)

authored by samuel.fm and committed by GitHub 2919a7da 2792b4ce

Changed files
+142 -84
src
components
ProgressGuide
lib
screens
state
queries
+2 -2
src/components/ProgressGuide/FollowDialog.tsx
··· 8 8 import {logEvent} from '#/lib/statsig/statsig' 9 9 import {isWeb} from '#/platform/detection' 10 10 import {useModerationOpts} from '#/state/preferences/moderation-opts' 11 - import {useActorSearchPaginated} from '#/state/queries/actor-search' 11 + import {useActorSearch} from '#/state/queries/actor-search' 12 12 import {usePreferencesQuery} from '#/state/queries/preferences' 13 13 import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' 14 14 import {useSession} from '#/state/session' ··· 128 128 isFetching: isFetchingSearchResults, 129 129 error: searchResultsError, 130 130 isError: isSearchResultsError, 131 - } = useActorSearchPaginated({ 131 + } = useActorSearch({ 132 132 enabled: !!searchText, 133 133 query: searchText, 134 134 })
+1
src/lib/constants.ts
··· 204 204 website: { 205 205 blog: { 206 206 initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`, 207 + searchTipsAndTricks: 'https://bsky.social/about/blog/05-31-2024-search', 207 208 }, 208 209 }, 209 210 }
+2 -2
src/screens/Search/Explore.tsx
··· 17 17 import {type MetricEvents} from '#/logger/metrics' 18 18 import {useLanguagePrefs} from '#/state/preferences/languages' 19 19 import {useModerationOpts} from '#/state/preferences/moderation-opts' 20 - import {RQKEY_ROOT_PAGINATED as useActorSearchPaginatedQueryKeyRoot} from '#/state/queries/actor-search' 20 + import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' 21 21 import { 22 22 type FeedPreviewItem, 23 23 useFeedPreviews, ··· 308 308 queryKey: [getSuggestedUsersQueryKeyRoot], 309 309 }), 310 310 qc.resetQueries({ 311 - queryKey: [useActorSearchPaginatedQueryKeyRoot], 311 + queryKey: [useActorSearchQueryKeyRoot], 312 312 }), 313 313 qc.resetQueries({ 314 314 queryKey: createGetSuggestedFeedsQueryKey(),
+106 -24
src/screens/Search/SearchResults.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {usePalette} from '#/lib/hooks/usePalette' 7 + import {urls} from '#/lib/constants' 8 + import {cleanError} from '#/lib/strings/errors' 8 9 import {augmentSearchQuery} from '#/lib/strings/helpers' 9 10 import {useActorSearch} from '#/state/queries/actor-search' 10 11 import {usePopularFeedsSearch} from '#/state/queries/feed' ··· 21 22 import * as FeedCard from '#/components/FeedCard' 22 23 import * as Layout from '#/components/Layout' 23 24 import {InlineLinkText} from '#/components/Link' 25 + import {ListFooter} from '#/components/Lists' 24 26 import {SearchError} from '#/components/SearchError' 25 27 import {Text} from '#/components/Typography' 26 28 ··· 112 114 } 113 115 114 116 function EmptyState({ 115 - message, 117 + messageText, 116 118 error, 117 119 children, 118 120 }: { 119 - message: string 121 + messageText: React.ReactNode 120 122 error?: string 121 123 children?: React.ReactNode 122 124 }) { ··· 126 128 <Layout.Content> 127 129 <View style={[a.p_xl]}> 128 130 <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> 129 - <Text style={[a.text_md]}>{message}</Text> 131 + <Text style={[a.text_md]}>{messageText}</Text> 130 132 131 133 {error && ( 132 134 <> ··· 155 157 ) 156 158 } 157 159 160 + function NoResultsText({query}: {query: string}) { 161 + const t = useTheme() 162 + const {_} = useLingui() 163 + 164 + return ( 165 + <> 166 + <Text style={[a.text_lg, t.atoms.text_contrast_high]}> 167 + <Trans> 168 + No results found for " 169 + <Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text> 170 + ". 171 + </Trans> 172 + </Text> 173 + {'\n\n'} 174 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 175 + <Trans context="english-only-resource"> 176 + Try a different search term, or{' '} 177 + <InlineLinkText 178 + label={_( 179 + msg({ 180 + message: 'read about how to use search filters', 181 + context: 'english-only-resource', 182 + }), 183 + )} 184 + to={urls.website.blog.searchTipsAndTricks} 185 + style={[a.text_md, a.leading_snug]}> 186 + read about how to use search filters 187 + </InlineLinkText> 188 + . 189 + </Trans> 190 + </Text> 191 + </> 192 + ) 193 + } 194 + 158 195 type SearchResultSlice = 159 196 | { 160 197 type: 'post' ··· 176 213 active: boolean 177 214 }): React.ReactNode => { 178 215 const {_} = useLingui() 179 - const {currentAccount} = useSession() 216 + const {currentAccount, hasSession} = useSession() 180 217 const [isPTR, setIsPTR] = useState(false) 181 - const isLoggedin = Boolean(currentAccount?.did) 182 218 183 219 const augmentedQuery = useMemo(() => { 184 220 return augmentSearchQuery(query || '', {did: currentAccount?.did}) ··· 195 231 hasNextPage, 196 232 } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) 197 233 198 - const pal = usePalette('default') 199 234 const t = useTheme() 200 235 const onPullToRefresh = useCallback(async () => { 201 236 setIsPTR(true) ··· 249 284 requestSwitchToAccount({requestedAccount: 'new'}) 250 285 } 251 286 252 - if (!isLoggedin) { 287 + if (!hasSession) { 253 288 return ( 254 289 <SearchError 255 290 title={_(msg`Search is currently unavailable when logged out`)}> 256 291 <Text style={[a.text_md, a.text_center, a.leading_snug]}> 257 292 <Trans> 258 293 <InlineLinkText 259 - style={[pal.link]} 260 294 label={_(msg`Sign in`)} 261 295 to={'#'} 262 296 onPress={showSignIn}> ··· 264 298 </InlineLinkText> 265 299 <Text style={t.atoms.text_contrast_medium}> or </Text> 266 300 <InlineLinkText 267 - style={[pal.link]} 268 301 label={_(msg`Create an account`)} 269 302 to={'#'} 270 303 onPress={showCreateAccount}> ··· 283 316 284 317 return error ? ( 285 318 <EmptyState 286 - message={_( 319 + messageText={_( 287 320 msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, 288 321 )} 289 - error={error.toString()} 322 + error={cleanError(error)} 290 323 /> 291 324 ) : ( 292 325 <> ··· 307 340 onRefresh={onPullToRefresh} 308 341 onEndReached={onEndReached} 309 342 desktopFixedHeight 310 - contentContainerStyle={{paddingBottom: 100}} 343 + ListFooterComponent={ 344 + <ListFooter 345 + isFetchingNextPage={isFetchingNextPage} 346 + hasNextPage={hasNextPage} 347 + /> 348 + } 311 349 /> 312 350 ) : ( 313 - <EmptyState message={_(msg`No results found for ${query}`)} /> 351 + <EmptyState messageText={<NoResultsText query={query} />} /> 314 352 )} 315 353 </> 316 354 ) : ( ··· 329 367 active: boolean 330 368 }): React.ReactNode => { 331 369 const {_} = useLingui() 370 + const {hasSession} = useSession() 371 + const [isPTR, setIsPTR] = useState(false) 332 372 333 - const {data: results, isFetched} = useActorSearch({ 373 + const { 374 + isFetched, 375 + data: results, 376 + isFetching, 377 + error, 378 + refetch, 379 + fetchNextPage, 380 + isFetchingNextPage, 381 + hasNextPage, 382 + } = useActorSearch({ 334 383 query, 335 384 enabled: active, 336 385 }) 337 386 338 - return isFetched && results ? ( 387 + const onPullToRefresh = useCallback(async () => { 388 + setIsPTR(true) 389 + await refetch() 390 + setIsPTR(false) 391 + }, [setIsPTR, refetch]) 392 + const onEndReached = useCallback(() => { 393 + if (!hasSession) return 394 + if (isFetching || !hasNextPage || error) return 395 + fetchNextPage() 396 + }, [isFetching, error, hasNextPage, fetchNextPage, hasSession]) 397 + 398 + const profiles = useMemo(() => { 399 + return results?.pages.flatMap(page => page.actors) || [] 400 + }, [results]) 401 + 402 + if (error) { 403 + return ( 404 + <EmptyState 405 + messageText={_( 406 + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, 407 + )} 408 + error={error.toString()} 409 + /> 410 + ) 411 + } 412 + 413 + return isFetched && profiles ? ( 339 414 <> 340 - {results.length ? ( 415 + {profiles.length ? ( 341 416 <List 342 - data={results} 417 + data={profiles} 343 418 renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} />} 344 419 keyExtractor={item => item.did} 420 + refreshing={isPTR} 421 + onRefresh={onPullToRefresh} 422 + onEndReached={onEndReached} 345 423 desktopFixedHeight 346 - contentContainerStyle={{paddingBottom: 100}} 424 + ListFooterComponent={ 425 + <ListFooter 426 + hasNextPage={hasNextPage && hasSession} 427 + isFetchingNextPage={isFetchingNextPage} 428 + /> 429 + } 347 430 /> 348 431 ) : ( 349 - <EmptyState message={_(msg`No results found for ${query}`)} /> 432 + <EmptyState messageText={<NoResultsText query={query} />} /> 350 433 )} 351 434 </> 352 435 ) : ( ··· 363 446 active: boolean 364 447 }): React.ReactNode => { 365 448 const t = useTheme() 366 - const {_} = useLingui() 367 449 368 450 const {data: results, isFetched} = usePopularFeedsSearch({ 369 451 query, ··· 378 460 renderItem={({item}) => ( 379 461 <View 380 462 style={[ 381 - a.border_b, 463 + a.border_t, 382 464 t.atoms.border_contrast_low, 383 465 a.px_lg, 384 466 a.py_lg, ··· 388 470 )} 389 471 keyExtractor={item => item.uri} 390 472 desktopFixedHeight 391 - contentContainerStyle={{paddingBottom: 100}} 473 + ListFooterComponent={<ListFooter />} 392 474 /> 393 475 ) : ( 394 - <EmptyState message={_(msg`No results found for ${query}`)} /> 476 + <EmptyState messageText={<NoResultsText query={query} />} /> 395 477 )} 396 478 </> 397 479 ) : (
+2 -2
src/screens/Search/util/useSuggestedUsers.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 3 import {useInterestsDisplayNames} from '#/lib/interests' 4 - import {useActorSearchPaginated} from '#/state/queries/actor-search' 4 + import {useActorSearch} from '#/state/queries/actor-search' 5 5 import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' 6 6 7 7 /** ··· 31 31 category, 32 32 overrideInterests, 33 33 }) 34 - const searched = useActorSearchPaginated({ 34 + const searched = useActorSearch({ 35 35 enabled: !!search, 36 36 // use user's app language translation for this value 37 37 query: category ? interestsDisplayNames[category] : '',
+2 -2
src/screens/StarterPack/Wizard/StepProfiles.tsx
··· 7 7 import {isNative} from '#/platform/detection' 8 8 import {useA11y} from '#/state/a11y' 9 9 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 10 - import {useActorSearchPaginated} from '#/state/queries/actor-search' 10 + import {useActorSearch} from '#/state/queries/actor-search' 11 11 import {List} from '#/view/com/util/List' 12 12 import {useWizardState} from '#/screens/StarterPack/Wizard/State' 13 13 import {atoms as a, useTheme} from '#/alf' ··· 36 36 data: topPages, 37 37 fetchNextPage, 38 38 isLoading: isLoadingTopPages, 39 - } = useActorSearchPaginated({ 39 + } = useActorSearch({ 40 40 query: encodeURIComponent('*'), 41 41 }) 42 42 const topFollowers = topPages?.pages
+27 -52
src/state/queries/actor-search.ts
··· 1 - import { 2 - type AppBskyActorDefs, 3 - type AppBskyActorSearchActors, 4 - } from '@atproto/api' 1 + import {type AppBskyActorSearchActors} from '@atproto/api' 5 2 import { 6 3 type InfiniteData, 7 4 keepPreviousData, 8 5 type QueryClient, 9 6 type QueryKey, 10 7 useInfiniteQuery, 11 - useQuery, 12 8 } from '@tanstack/react-query' 13 9 14 10 import {STALE} from '#/state/queries' 15 11 import {useAgent} from '#/state/session' 16 12 17 - const RQKEY_ROOT = 'actor-search' 18 - export const RQKEY = (query: string) => [RQKEY_ROOT, query] 19 - 20 - export const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` 21 - export const RQKEY_PAGINATED = (query: string, limit?: number) => [ 22 - RQKEY_ROOT_PAGINATED, 13 + export const RQKEY_ROOT = 'actor-search' 14 + export const RQKEY = (query: string, limit?: number) => [ 15 + RQKEY_ROOT, 23 16 query, 24 17 limit, 25 18 ] ··· 27 20 export function useActorSearch({ 28 21 query, 29 22 enabled, 30 - }: { 31 - query: string 32 - enabled?: boolean 33 - }) { 34 - const agent = useAgent() 35 - return useQuery<AppBskyActorDefs.ProfileView[]>({ 36 - staleTime: STALE.MINUTES.ONE, 37 - queryKey: RQKEY(query || ''), 38 - async queryFn() { 39 - const res = await agent.searchActors({ 40 - q: query, 41 - }) 42 - return res.data.actors 43 - }, 44 - enabled: enabled && !!query, 45 - }) 46 - } 47 - 48 - export function useActorSearchPaginated({ 49 - query, 50 - enabled, 51 23 maintainData, 52 24 limit = 25, 53 25 }: { ··· 65 37 string | undefined 66 38 >({ 67 39 staleTime: STALE.MINUTES.FIVE, 68 - queryKey: RQKEY_PAGINATED(query, limit), 40 + queryKey: RQKEY(query, limit), 69 41 queryFn: async ({pageParam}) => { 70 42 const res = await agent.searchActors({ 71 43 q: query, ··· 78 50 initialPageParam: undefined, 79 51 getNextPageParam: lastPage => lastPage.cursor, 80 52 placeholderData: maintainData ? keepPreviousData : undefined, 53 + select, 81 54 }) 82 55 } 83 56 57 + function select(data: InfiniteData<AppBskyActorSearchActors.OutputSchema>) { 58 + // enforce uniqueness 59 + const dids = new Set() 60 + 61 + return { 62 + ...data, 63 + pages: data.pages.map(page => ({ 64 + actors: page.actors.filter(actor => { 65 + if (dids.has(actor.did)) { 66 + return false 67 + } 68 + dids.add(actor.did) 69 + return true 70 + }), 71 + })), 72 + } 73 + } 74 + 84 75 export function* findAllProfilesInQueryData( 85 76 queryClient: QueryClient, 86 77 did: string, 87 78 ) { 88 - const queryDatas = queryClient.getQueriesData<AppBskyActorDefs.ProfileView[]>( 89 - { 90 - queryKey: [RQKEY_ROOT], 91 - }, 92 - ) 93 - for (const [_queryKey, queryData] of queryDatas) { 94 - if (!queryData) { 95 - continue 96 - } 97 - for (const actor of queryData) { 98 - if (actor.did === did) { 99 - yield actor 100 - } 101 - } 102 - } 103 - 104 - const queryDatasPaginated = queryClient.getQueriesData< 79 + const queryDatas = queryClient.getQueriesData< 105 80 InfiniteData<AppBskyActorSearchActors.OutputSchema> 106 81 >({ 107 - queryKey: [RQKEY_ROOT_PAGINATED], 82 + queryKey: [RQKEY_ROOT], 108 83 }) 109 - for (const [_queryKey, queryData] of queryDatasPaginated) { 84 + for (const [_queryKey, queryData] of queryDatas) { 110 85 if (!queryData) { 111 86 continue 112 87 }