Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at main 614 lines 16 kB view raw
1import {memo, useCallback, useMemo, useState} from 'react' 2import {ActivityIndicator, View} from 'react-native' 3import {type AppBskyFeedDefs} from '@atproto/api' 4import {Trans, useLingui} from '@lingui/react/macro' 5 6import {urls} from '#/lib/constants' 7import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 8import {useCallOnce} from '#/lib/once' 9import {cleanError} from '#/lib/strings/errors' 10import {augmentSearchQuery} from '#/lib/strings/helpers' 11import {useActorSearch} from '#/state/queries/actor-search' 12import {usePopularFeedsSearch} from '#/state/queries/feed' 13import {useSearchPostsQuery} from '#/state/queries/search-posts' 14import {useSession} from '#/state/session' 15import {useLoggedOutViewControls} from '#/state/shell/logged-out' 16import {useCloseAllActiveElements} from '#/state/util' 17import {Pager} from '#/view/com/pager/Pager' 18import {TabBar} from '#/view/com/pager/TabBar' 19import {Post} from '#/view/com/post/Post' 20import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 21import {List} from '#/view/com/util/List' 22import {atoms as a, useTheme, web} from '#/alf' 23import * as FeedCard from '#/components/FeedCard' 24import * as Layout from '#/components/Layout' 25import {InlineLinkText} from '#/components/Link' 26import {ListFooter} from '#/components/Lists' 27import {SearchError} from '#/components/SearchError' 28import {Text} from '#/components/Typography' 29import {type Metrics, useAnalytics} from '#/analytics' 30import type * as bsky from '#/types/bsky' 31 32let SearchResults = ({ 33 query, 34 queryWithParams, 35 activeTab, 36 onPageSelected, 37 headerHeight, 38 initialPage = 0, 39}: { 40 query: string 41 queryWithParams: string 42 activeTab: number 43 onPageSelected: (page: number) => void 44 headerHeight: number 45 initialPage?: number 46}): React.ReactNode => { 47 const {t: l} = useLingui() 48 49 const sections = useMemo(() => { 50 if (!queryWithParams) return [] 51 const noParams = queryWithParams === query 52 return [ 53 { 54 title: l`Top`, 55 component: ( 56 <SearchScreenPostResults 57 query={queryWithParams} 58 sort="top" 59 active={activeTab === 0} 60 /> 61 ), 62 }, 63 { 64 title: l`Latest`, 65 component: ( 66 <SearchScreenPostResults 67 query={queryWithParams} 68 sort="latest" 69 active={activeTab === 1} 70 /> 71 ), 72 }, 73 noParams && { 74 title: l`People`, 75 component: ( 76 <SearchScreenUserResults query={query} active={activeTab === 2} /> 77 ), 78 }, 79 noParams && { 80 title: l`Feeds`, 81 component: ( 82 <SearchScreenFeedsResults query={query} active={activeTab === 3} /> 83 ), 84 }, 85 ].filter(Boolean) as { 86 title: string 87 component: React.ReactNode 88 }[] 89 }, [l, query, queryWithParams, activeTab]) 90 91 // There may be fewer tabs after changing the search options. 92 const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage 93 94 return ( 95 <Pager 96 onPageSelected={onPageSelected} 97 renderTabBar={props => ( 98 <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}> 99 <TabBar items={sections.map(section => section.title)} {...props} /> 100 </Layout.Center> 101 )} 102 initialPage={selectedPage}> 103 {sections.map((section, i) => ( 104 <View key={i}>{section.component}</View> 105 ))} 106 </Pager> 107 ) 108} 109SearchResults = memo(SearchResults) 110export {SearchResults} 111 112function Loader() { 113 const t = useTheme() 114 115 return ( 116 <Layout.Content> 117 <View style={[a.py_xl]}> 118 <ActivityIndicator color={t.palette.primary_500} /> 119 </View> 120 </Layout.Content> 121 ) 122} 123 124function EmptyState({ 125 messageText, 126 error, 127 children, 128}: { 129 messageText: React.ReactNode 130 error?: string 131 children?: React.ReactNode 132}) { 133 const t = useTheme() 134 135 return ( 136 <Layout.Content> 137 <View style={[a.p_xl]}> 138 <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> 139 <Text style={[a.text_md]}>{messageText}</Text> 140 141 {error && ( 142 <> 143 <View 144 style={[ 145 { 146 marginVertical: 12, 147 height: 1, 148 width: '100%', 149 backgroundColor: t.atoms.text.color, 150 opacity: 0.2, 151 }, 152 ]} 153 /> 154 155 <Text style={[t.atoms.text_contrast_medium]}> 156 <Trans>Error: {error}</Trans> 157 </Text> 158 </> 159 )} 160 161 {children} 162 </View> 163 </View> 164 </Layout.Content> 165 ) 166} 167 168function NoResultsText({ 169 query, 170}: { 171 sort?: 'top' | 'latest' | 'people' | 'feeds' 172 query: string 173}) { 174 const t = useTheme() 175 const {t: l} = useLingui() 176 177 return ( 178 <> 179 <Text style={[a.text_lg, t.atoms.text_contrast_high]}> 180 <Trans> 181 No results found for 182 <Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text> 183 . 184 </Trans> 185 </Text> 186 {'\n\n'} 187 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 188 <Trans context="english-only-resource"> 189 Try a different search term, or{' '} 190 <InlineLinkText 191 label={l({ 192 message: 'read about how to use search filters', 193 context: 'english-only-resource', 194 })} 195 to={urls.website.blog.searchTipsAndTricks} 196 style={[a.text_md, a.leading_snug]}> 197 read about how to use search filters 198 </InlineLinkText> 199 . 200 </Trans> 201 </Text> 202 </> 203 ) 204} 205 206type SearchResultSlice = 207 | { 208 type: 'post' 209 key: string 210 post: AppBskyFeedDefs.PostView 211 } 212 | { 213 type: 'loadingMore' 214 key: string 215 } 216 217let SearchScreenPostResults = ({ 218 query, 219 sort, 220 active, 221}: { 222 query: string 223 sort?: 'top' | 'latest' 224 active: boolean 225}): React.ReactNode => { 226 const ax = useAnalytics() 227 const {t: l} = useLingui() 228 const {currentAccount, hasSession} = useSession() 229 const [isPTR, setIsPTR] = useState(false) 230 const trackPostView = usePostViewTracking('SearchResults') 231 232 const augmentedQuery = useMemo(() => { 233 return augmentSearchQuery(query || '', {did: currentAccount?.did}) 234 }, [query, currentAccount]) 235 236 const { 237 isFetched, 238 data: results, 239 isFetching, 240 error, 241 refetch, 242 fetchNextPage, 243 isFetchingNextPage, 244 hasNextPage, 245 } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) 246 247 const t = useTheme() 248 const onPullToRefresh = useCallback(async () => { 249 setIsPTR(true) 250 await refetch() 251 setIsPTR(false) 252 }, [setIsPTR, refetch]) 253 const onEndReached = useCallback(() => { 254 if (isFetching || !hasNextPage || error) return 255 void fetchNextPage() 256 }, [isFetching, error, hasNextPage, fetchNextPage]) 257 258 const posts = useMemo(() => { 259 return results?.pages.flatMap(page => page.posts) || [] 260 }, [results]) 261 const items = useMemo(() => { 262 let temp: SearchResultSlice[] = [] 263 264 const seenUris = new Set() 265 for (const post of posts) { 266 if (seenUris.has(post.uri)) { 267 continue 268 } 269 temp.push({ 270 type: 'post', 271 key: post.uri, 272 post, 273 }) 274 seenUris.add(post.uri) 275 } 276 277 if (isFetchingNextPage) { 278 temp.push({ 279 type: 'loadingMore', 280 key: 'loadingMore', 281 }) 282 } 283 284 return temp 285 }, [posts, isFetchingNextPage]) 286 287 const closeAllActiveElements = useCloseAllActiveElements() 288 const {requestSwitchToAccount} = useLoggedOutViewControls() 289 290 const fireTracking = useCallOnce(() => { 291 if (sort) { 292 // ts only 293 ax.metric('search:results:loaded', { 294 tab: sort, 295 initialCount: items.length, 296 }) 297 } 298 }) 299 if (isFetched && sort) { 300 fireTracking() 301 } 302 303 const showSignIn = () => { 304 closeAllActiveElements() 305 requestSwitchToAccount({requestedAccount: 'none'}) 306 } 307 308 const showCreateAccount = () => { 309 closeAllActiveElements() 310 requestSwitchToAccount({requestedAccount: 'new'}) 311 } 312 313 if (!hasSession) { 314 return ( 315 <SearchError title={l`Search is currently unavailable when logged out`}> 316 <Text style={[a.text_md, a.text_center, a.leading_snug]}> 317 <Trans> 318 <InlineLinkText label={l`Sign in`} to="#" onPress={showSignIn}> 319 Sign in 320 </InlineLinkText> 321 <Text style={t.atoms.text_contrast_medium}> or </Text> 322 <InlineLinkText 323 label={l`Create an account`} 324 to={'#'} 325 onPress={showCreateAccount}> 326 create an account 327 </InlineLinkText> 328 <Text> </Text> 329 <Text style={t.atoms.text_contrast_medium}> 330 to search for news, sports, politics, and everything else 331 happening on Bluesky. 332 </Text> 333 </Trans> 334 </Text> 335 </SearchError> 336 ) 337 } 338 339 return error ? ( 340 <EmptyState 341 messageText={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`} 342 error={cleanError(error)} 343 /> 344 ) : ( 345 <> 346 {isFetched ? ( 347 <> 348 {posts.length ? ( 349 <List 350 data={items} 351 renderItem={({ 352 item, 353 index, 354 }: { 355 item: SearchResultSlice 356 index: number 357 }) => { 358 if (item.type === 'post') { 359 return ( 360 <SearchPost from={sort} position={index} post={item.post} /> 361 ) 362 } else { 363 return null 364 } 365 }} 366 keyExtractor={(item: SearchResultSlice) => item.key} 367 refreshing={isPTR} 368 onRefresh={() => { 369 void onPullToRefresh() 370 }} 371 onEndReached={onEndReached} 372 onItemSeen={(item: SearchResultSlice) => { 373 if (item.type === 'post') { 374 trackPostView(item.post) 375 } 376 }} 377 desktopFixedHeight 378 ListFooterComponent={ 379 <ListFooter 380 isFetchingNextPage={isFetchingNextPage} 381 hasNextPage={hasNextPage} 382 /> 383 } 384 /> 385 ) : ( 386 <EmptyState messageText={<NoResultsText query={query} />} /> 387 )} 388 </> 389 ) : ( 390 <Loader /> 391 )} 392 </> 393 ) 394} 395SearchScreenPostResults = memo(SearchScreenPostResults) 396 397function SearchPost({ 398 from, 399 position, 400 post, 401}: { 402 from: Metrics['search:result:press']['tab'] 403 position: Metrics['search:result:press']['position'] 404 post: AppBskyFeedDefs.PostView 405}) { 406 const ax = useAnalytics() 407 408 const onBeforePress = useCallback(() => { 409 ax.metric('search:result:press', { 410 tab: from, 411 resultType: 'post', 412 position, 413 uri: post.uri, 414 }) 415 }, [ax, from, position, post]) 416 417 return <Post post={post} onBeforePress={onBeforePress} /> 418} 419 420let SearchScreenUserResults = ({ 421 query, 422 active, 423}: { 424 query: string 425 active: boolean 426}): React.ReactNode => { 427 const ax = useAnalytics() 428 const {t: l} = useLingui() 429 const {hasSession} = useSession() 430 const [isPTR, setIsPTR] = useState(false) 431 432 const { 433 isFetched, 434 data: results, 435 isFetching, 436 error, 437 refetch, 438 fetchNextPage, 439 isFetchingNextPage, 440 hasNextPage, 441 } = useActorSearch({ 442 query, 443 enabled: active, 444 }) 445 446 const onPullToRefresh = useCallback(async () => { 447 setIsPTR(true) 448 await refetch() 449 setIsPTR(false) 450 }, [setIsPTR, refetch]) 451 const onEndReached = useCallback(() => { 452 if (!hasSession) return 453 if (isFetching || !hasNextPage || error) return 454 void fetchNextPage() 455 }, [isFetching, error, hasNextPage, fetchNextPage, hasSession]) 456 457 const profiles = useMemo(() => { 458 return results?.pages.flatMap(page => page.actors) || [] 459 }, [results]) 460 461 const fireTracking = useCallOnce(() => { 462 ax.metric('search:results:loaded', { 463 tab: 'people', 464 initialCount: profiles.length, 465 }) 466 }) 467 if (isFetched) { 468 fireTracking() 469 } 470 471 if (error) { 472 return ( 473 <EmptyState 474 messageText={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`} 475 error={error.toString()} 476 /> 477 ) 478 } 479 480 return isFetched && profiles ? ( 481 <> 482 {profiles.length ? ( 483 <List 484 data={profiles} 485 renderItem={({ 486 item, 487 index, 488 }: { 489 item: bsky.profile.AnyProfileView 490 index: number 491 }) => <SearchScreenProfileButton position={index} profile={item} />} 492 keyExtractor={(item: bsky.profile.AnyProfileView) => item.did} 493 refreshing={isPTR} 494 onRefresh={() => void onPullToRefresh()} 495 onEndReached={onEndReached} 496 desktopFixedHeight 497 ListFooterComponent={ 498 <ListFooter 499 hasNextPage={hasNextPage && hasSession} 500 isFetchingNextPage={isFetchingNextPage} 501 /> 502 } 503 /> 504 ) : ( 505 <EmptyState messageText={<NoResultsText query={query} />} /> 506 )} 507 </> 508 ) : ( 509 <Loader /> 510 ) 511} 512SearchScreenUserResults = memo(SearchScreenUserResults) 513 514function SearchScreenProfileButton({ 515 position, 516 profile, 517}: { 518 position: number 519 profile: bsky.profile.AnyProfileView 520}) { 521 const ax = useAnalytics() 522 523 const handlePress = () => { 524 ax.metric('search:result:press', { 525 tab: 'people', 526 resultType: 'profile', 527 position, 528 uri: profile.did, 529 }) 530 } 531 return <ProfileCardWithFollowBtn profile={profile} onPress={handlePress} /> 532} 533 534let SearchScreenFeedsResults = ({ 535 query, 536 active, 537}: { 538 query: string 539 active: boolean 540}): React.ReactNode => { 541 const ax = useAnalytics() 542 const t = useTheme() 543 544 const {data: results, isFetched} = usePopularFeedsSearch({ 545 query, 546 enabled: active, 547 }) 548 549 const fireTracking = useCallOnce(() => { 550 ax.metric('search:results:loaded', { 551 tab: 'feeds', 552 initialCount: results?.length ?? 0, 553 }) 554 }) 555 if (isFetched) { 556 fireTracking() 557 } 558 559 return isFetched && results ? ( 560 <> 561 {results.length ? ( 562 <List 563 data={results} 564 renderItem={({ 565 item, 566 index, 567 }: { 568 item: AppBskyFeedDefs.GeneratorView 569 index: number 570 }) => ( 571 <View 572 style={[ 573 a.border_t, 574 t.atoms.border_contrast_low, 575 a.px_lg, 576 a.py_lg, 577 ]}> 578 <SearchFeedCard position={index} view={item} /> 579 </View> 580 )} 581 keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri} 582 desktopFixedHeight 583 ListFooterComponent={<ListFooter />} 584 /> 585 ) : ( 586 <EmptyState messageText={<NoResultsText query={query} />} /> 587 )} 588 </> 589 ) : ( 590 <Loader /> 591 ) 592} 593SearchScreenFeedsResults = memo(SearchScreenFeedsResults) 594 595function SearchFeedCard({ 596 position, 597 view, 598}: { 599 position: number 600 view: AppBskyFeedDefs.GeneratorView 601}) { 602 const ax = useAnalytics() 603 604 const handleOnPress = () => { 605 ax.metric('search:result:press', { 606 tab: 'feeds', 607 resultType: 'feed', 608 position, 609 uri: view.uri, 610 }) 611 } 612 613 return <FeedCard.Default view={view} onPress={handleOnPress} /> 614}