Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at ece6dc251cdb7eaf260819a4005b3a3e3e74ac8b 920 lines 28 kB view raw
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import {ScrollView, View} from 'react-native' 3import Animated, { 4 Easing, 5 FadeIn, 6 FadeOut, 7 LayoutAnimationConfig, 8 LinearTransition, 9} from 'react-native-reanimated' 10import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 11import {msg} from '@lingui/core/macro' 12import {useLingui} from '@lingui/react' 13import {Trans} from '@lingui/react/macro' 14import {useNavigation} from '@react-navigation/native' 15 16import {type NavigationProp} from '#/lib/routes/types' 17import {useModerationOpts} from '#/state/preferences/moderation-opts' 18import {useGetPopularFeedsQuery} from '#/state/queries/feed' 19import {type FeedDescriptor} from '#/state/queries/post-feed' 20import {useProfilesQuery} from '#/state/queries/profile' 21import { 22 useSuggestedFollowsByActorQuery, 23 useSuggestedFollowsQuery, 24} from '#/state/queries/suggested-follows' 25import {useSession} from '#/state/session' 26import * as userActionHistory from '#/state/userActionHistory' 27import {type SeenPost} from '#/state/userActionHistory' 28import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 29import { 30 atoms as a, 31 native, 32 useBreakpoints, 33 useTheme, 34 type ViewStyleProp, 35 web, 36} from '#/alf' 37import {Button, ButtonIcon, ButtonText} from '#/components/Button' 38import {useDialogControl} from '#/components/Dialog' 39import * as FeedCard from '#/components/FeedCard' 40import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 41import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 42import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 43import {InlineLinkText} from '#/components/Link' 44import * as ProfileCard from '#/components/ProfileCard' 45import {Text} from '#/components/Typography' 46import {type Metrics, useAnalytics} from '#/analytics' 47import {IS_IOS} from '#/env' 48import type * as bsky from '#/types/bsky' 49import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' 50import {ProgressGuideList} from './ProgressGuide/List' 51 52const DISMISS_ANIMATION_DURATION = 200 53 54const MOBILE_CARD_WIDTH = 165 55const FINAL_CARD_WIDTH = 120 56 57function CardOuter({ 58 children, 59 style, 60}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 61 const t = useTheme() 62 const {gtMobile} = useBreakpoints() 63 return ( 64 <View 65 testID="CardOuter" 66 style={[ 67 a.flex_1, 68 a.w_full, 69 a.p_md, 70 a.rounded_lg, 71 a.border, 72 t.atoms.bg, 73 t.atoms.shadow_sm, 74 t.atoms.border_contrast_low, 75 !gtMobile && { 76 width: MOBILE_CARD_WIDTH, 77 }, 78 style, 79 ]}> 80 {children} 81 </View> 82 ) 83} 84 85export function SuggestedFollowPlaceholder() { 86 return ( 87 <CardOuter> 88 <ProfileCard.Outer> 89 <View 90 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> 91 <ProfileCard.AvatarPlaceholder size={88} /> 92 <ProfileCard.NamePlaceholder /> 93 <View style={[a.w_full]}> 94 <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> 95 </View> 96 </View> 97 98 <ProfileCard.FollowButtonPlaceholder /> 99 </ProfileCard.Outer> 100 </CardOuter> 101 ) 102} 103 104export function SuggestedFeedsCardPlaceholder() { 105 return ( 106 <CardOuter style={[a.gap_sm]}> 107 <FeedCard.Header> 108 <FeedCard.AvatarPlaceholder /> 109 <FeedCard.TitleAndBylinePlaceholder creator /> 110 </FeedCard.Header> 111 112 <FeedCard.DescriptionPlaceholder /> 113 </CardOuter> 114 ) 115} 116 117function getRank(seenPost: SeenPost): string { 118 let tier: string 119 if (seenPost.feedContext === 'popfriends') { 120 tier = 'a' 121 } else if (seenPost.feedContext?.startsWith('cluster')) { 122 tier = 'b' 123 } else if (seenPost.feedContext === 'popcluster') { 124 tier = 'c' 125 } else if (seenPost.feedContext?.startsWith('ntpc')) { 126 tier = 'd' 127 } else if (seenPost.feedContext?.startsWith('t-')) { 128 tier = 'e' 129 } else if (seenPost.feedContext === 'nettop') { 130 tier = 'f' 131 } else { 132 tier = 'g' 133 } 134 let score = Math.round( 135 Math.log( 136 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount, 137 ), 138 ) 139 if (seenPost.isFollowedBy || Math.random() > 0.9) { 140 score *= 2 141 } 142 const rank = 100 - score 143 return `${tier}-${rank}` 144} 145 146function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 { 147 const rankA = getRank(postA) 148 const rankB = getRank(postB) 149 // Yes, we're comparing strings here. 150 // The "larger" string means a worse rank. 151 if (rankA > rankB) { 152 return 1 153 } else if (rankA < rankB) { 154 return -1 155 } else { 156 return 0 157 } 158} 159 160function useExperimentalSuggestedUsersQuery() { 161 const {currentAccount} = useSession() 162 const userActionSnapshot = userActionHistory.useActionHistorySnapshot() 163 const dids = useMemo(() => { 164 const {likes, follows, followSuggestions, seen} = userActionSnapshot 165 const likeDids = likes 166 .map(l => new AtUri(l)) 167 .map(uri => uri.host) 168 .filter(did => !follows.includes(did)) 169 let suggestedDids: string[] = [] 170 if (followSuggestions.length > 0) { 171 suggestedDids = [ 172 // It's ok if these will pick the same item (weighed by its frequency) 173 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 174 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 175 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 176 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 177 ] 178 } 179 const seenDids = seen 180 .sort(sortSeenPosts) 181 .map(l => new AtUri(l.uri)) 182 .map(uri => uri.host) 183 return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( 184 did => did !== currentAccount?.did, 185 ) 186 }, [userActionSnapshot, currentAccount]) 187 const {data, isLoading, error} = useProfilesQuery({ 188 handles: dids.slice(0, 16), 189 }) 190 191 const profiles = data 192 ? data.profiles.filter(profile => { 193 return !profile.viewer?.following 194 }) 195 : [] 196 197 return { 198 isLoading, 199 error, 200 profiles: profiles.slice(0, 6), 201 } 202} 203 204export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { 205 const {currentAccount} = useSession() 206 const [feedType, feedUriOrDid] = feed.split('|') 207 if (feedType === 'author') { 208 if (currentAccount?.did === feedUriOrDid) { 209 return null 210 } else { 211 return <SuggestedFollowsProfile did={feedUriOrDid} /> 212 } 213 } else { 214 return <SuggestedFollowsHome /> 215 } 216} 217 218export function SuggestedFollowsProfile({did}: {did: string}) { 219 const {gtMobile} = useBreakpoints() 220 const moderationOpts = useModerationOpts() 221 const maxLength = gtMobile ? 4 : 6 222 const { 223 isLoading: isSuggestionsLoading, 224 data, 225 error, 226 } = useSuggestedFollowsByActorQuery({ 227 did, 228 }) 229 const { 230 data: moreSuggestions, 231 fetchNextPage, 232 hasNextPage, 233 isFetchingNextPage, 234 } = useSuggestedFollowsQuery({limit: 25}) 235 236 const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) 237 238 const onDismiss = useCallback((dismissedDid: string) => { 239 setDismissedDids(prev => new Set(prev).add(dismissedDid)) 240 }, []) 241 242 // Combine profiles from the actor-specific query with fallback suggestions 243 const allProfiles = useMemo(() => { 244 const actorProfiles = data?.suggestions ?? [] 245 const fallbackProfiles = 246 moreSuggestions?.pages.flatMap(page => 247 page.actors.map(actor => ({actor, recId: page.recId})), 248 ) ?? [] 249 250 // Dedupe by did, preferring actor-specific profiles 251 const seen = new Set<string>() 252 const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = [] 253 254 for (const profile of actorProfiles) { 255 if (!seen.has(profile.did)) { 256 seen.add(profile.did) 257 combined.push({actor: profile, recId: data?.recId}) 258 } 259 } 260 261 for (const profile of fallbackProfiles) { 262 if (!seen.has(profile.actor.did) && profile.actor.did !== did) { 263 seen.add(profile.actor.did) 264 combined.push(profile) 265 } 266 } 267 268 return combined 269 }, [data?.suggestions, moreSuggestions?.pages, did, data?.recId]) 270 271 const filteredProfiles = useMemo(() => { 272 return allProfiles.filter(p => !dismissedDids.has(p.actor.did)) 273 }, [allProfiles, dismissedDids]) 274 275 // Fetch more when running low 276 useEffect(() => { 277 if ( 278 moderationOpts && 279 filteredProfiles.length < maxLength && 280 hasNextPage && 281 !isFetchingNextPage 282 ) { 283 void fetchNextPage() 284 } 285 }, [ 286 filteredProfiles.length, 287 maxLength, 288 hasNextPage, 289 isFetchingNextPage, 290 fetchNextPage, 291 moderationOpts, 292 ]) 293 294 return ( 295 <ProfileGrid 296 isSuggestionsLoading={isSuggestionsLoading} 297 profiles={filteredProfiles} 298 totalProfileCount={allProfiles.length} 299 error={error} 300 viewContext="profile" 301 onDismiss={onDismiss} 302 /> 303 ) 304} 305 306export function SuggestedFollowsHome() { 307 const {gtMobile} = useBreakpoints() 308 const moderationOpts = useModerationOpts() 309 const maxLength = gtMobile ? 4 : 6 310 const { 311 isLoading: isSuggestionsLoading, 312 profiles: experimentalProfiles, 313 error: experimentalError, 314 } = useExperimentalSuggestedUsersQuery() 315 const { 316 data: moreSuggestions, 317 fetchNextPage, 318 hasNextPage, 319 isFetchingNextPage, 320 error: suggestionsError, 321 } = useSuggestedFollowsQuery({limit: 25}) 322 323 const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) 324 325 const onDismiss = useCallback((did: string) => { 326 setDismissedDids(prev => new Set(prev).add(did)) 327 }, []) 328 329 // Combine profiles from experimental query with paginated suggestions 330 const allProfiles = useMemo(() => { 331 const fallbackProfiles = 332 moreSuggestions?.pages.flatMap(page => 333 page.actors.map(actor => ({actor, recId: page.recId})), 334 ) ?? [] 335 336 // Dedupe by did, preferring experimental profiles 337 const seen = new Set<string>() 338 const combined: Array<{ 339 actor: bsky.profile.AnyProfileView 340 recId?: number 341 }> = [] 342 343 for (const profile of experimentalProfiles) { 344 if (!seen.has(profile.did)) { 345 seen.add(profile.did) 346 combined.push({actor: profile, recId: undefined}) 347 } 348 } 349 350 for (const profile of fallbackProfiles) { 351 if (!seen.has(profile.actor.did)) { 352 seen.add(profile.actor.did) 353 combined.push(profile) 354 } 355 } 356 357 return combined 358 }, [experimentalProfiles, moreSuggestions?.pages]) 359 360 const filteredProfiles = useMemo(() => { 361 return allProfiles.filter(p => !dismissedDids.has(p.actor.did)) 362 }, [allProfiles, dismissedDids]) 363 364 // Fetch more when running low 365 useEffect(() => { 366 if ( 367 moderationOpts && 368 filteredProfiles.length < maxLength && 369 hasNextPage && 370 !isFetchingNextPage 371 ) { 372 void fetchNextPage() 373 } 374 }, [ 375 filteredProfiles.length, 376 maxLength, 377 hasNextPage, 378 isFetchingNextPage, 379 fetchNextPage, 380 moderationOpts, 381 ]) 382 383 return ( 384 <ProfileGrid 385 isSuggestionsLoading={isSuggestionsLoading} 386 profiles={filteredProfiles} 387 totalProfileCount={allProfiles.length} 388 error={experimentalError || suggestionsError} 389 viewContext="feed" 390 onDismiss={onDismiss} 391 /> 392 ) 393} 394 395export function ProfileGrid({ 396 isSuggestionsLoading, 397 error, 398 profiles, 399 totalProfileCount, 400 viewContext = 'feed', 401 onDismiss, 402 isVisible = true, 403}: { 404 isSuggestionsLoading: boolean 405 profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[] 406 totalProfileCount?: number 407 error: Error | null 408 viewContext: 'profile' | 'profileHeader' | 'feed' 409 onDismiss?: (did: string) => void 410 isVisible?: boolean 411}) { 412 const t = useTheme() 413 const ax = useAnalytics() 414 const {_} = useLingui() 415 const moderationOpts = useModerationOpts() 416 const {gtMobile} = useBreakpoints() 417 const followDialogControl = useDialogControl() 418 419 const isLoading = isSuggestionsLoading || !moderationOpts 420 const isProfileHeaderContext = viewContext === 'profileHeader' 421 const isFeedContext = viewContext === 'feed' 422 423 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 424 const minLength = gtMobile ? 3 : 4 425 426 // Track seen profiles 427 const seenProfilesRef = useRef<Set<string>>(new Set()) 428 const containerRef = useRef<View>(null) 429 const hasTrackedRef = useRef(false) 430 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext 431 ? 'InterstitialDiscover' 432 : isProfileHeaderContext 433 ? 'Profile' 434 : 'InterstitialProfile' 435 436 // Callback to fire seen events 437 const fireSeen = useCallback(() => { 438 if (isLoading || error || !profiles.length) return 439 if (hasTrackedRef.current) return 440 hasTrackedRef.current = true 441 442 const profilesToShow = profiles.slice(0, maxLength) 443 profilesToShow.forEach((profile, index) => { 444 if (!seenProfilesRef.current.has(profile.actor.did)) { 445 seenProfilesRef.current.add(profile.actor.did) 446 ax.metric('suggestedUser:seen', { 447 logContext, 448 recId: profile.recId, 449 position: index, 450 suggestedDid: profile.actor.did, 451 category: null, 452 }) 453 } 454 }) 455 }, [ax, isLoading, error, profiles, maxLength, logContext]) 456 457 // For profile header, fire when isVisible becomes true 458 useEffect(() => { 459 if (isProfileHeaderContext) { 460 if (!isVisible) { 461 hasTrackedRef.current = false 462 return 463 } 464 fireSeen() 465 } 466 }, [isVisible, isProfileHeaderContext, fireSeen]) 467 468 // For feed interstitials, use IntersectionObserver to detect actual visibility 469 useEffect(() => { 470 if (isProfileHeaderContext) return // handled above 471 if (isLoading || error || !profiles.length) return 472 473 const node = containerRef.current 474 if (!node) return 475 476 // Use IntersectionObserver on web to detect when actually visible 477 if (typeof IntersectionObserver !== 'undefined') { 478 const observer = new IntersectionObserver( 479 entries => { 480 if (entries[0]?.isIntersecting) { 481 fireSeen() 482 observer.disconnect() 483 } 484 }, 485 {threshold: 0.5}, 486 ) 487 // @ts-ignore - web only 488 observer.observe(node) 489 return () => observer.disconnect() 490 } else { 491 // On native, delay slightly to account for layout shifts during hydration 492 const timeout = setTimeout(() => { 493 fireSeen() 494 }, 500) 495 return () => clearTimeout(timeout) 496 } 497 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 498 499 const content = isLoading 500 ? Array(maxLength) 501 .fill(0) 502 .map((_, i) => ( 503 <View 504 key={i} 505 style={[ 506 a.flex_1, 507 gtMobile && 508 web([ 509 a.flex_0, 510 a.flex_grow, 511 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 512 ]), 513 ]}> 514 <SuggestedFollowPlaceholder /> 515 </View> 516 )) 517 : error || !profiles.length 518 ? null 519 : profiles.slice(0, maxLength).map((profile, index) => ( 520 <Animated.View 521 key={profile.actor.did} 522 layout={native( 523 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing( 524 Easing.out(Easing.exp), 525 ), 526 )} 527 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)} 528 // for web, as the cards are static, not in a list 529 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))} 530 style={[ 531 a.flex_1, 532 gtMobile && 533 web([ 534 a.flex_0, 535 a.flex_grow, 536 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 537 ]), 538 ]}> 539 <ProfileCard.Link 540 profile={profile.actor} 541 onPress={() => { 542 ax.metric('suggestedUser:press', { 543 logContext: isFeedContext 544 ? 'InterstitialDiscover' 545 : 'InterstitialProfile', 546 recId: profile.recId, 547 position: index, 548 suggestedDid: profile.actor.did, 549 category: null, 550 }) 551 }} 552 style={[a.flex_1]}> 553 {({hovered, pressed}) => ( 554 <CardOuter 555 style={[ 556 (hovered || pressed) && t.atoms.border_contrast_high, 557 ]}> 558 <ProfileCard.Outer> 559 {onDismiss && ( 560 <Button 561 label={_(msg`Dismiss this suggestion`)} 562 onPress={e => { 563 e.preventDefault() 564 onDismiss(profile.actor.did) 565 ax.metric('suggestedUser:dismiss', { 566 logContext: isFeedContext 567 ? 'InterstitialDiscover' 568 : 'InterstitialProfile', 569 position: index, 570 suggestedDid: profile.actor.did, 571 recId: profile.recId, 572 }) 573 }} 574 style={[ 575 a.absolute, 576 a.z_10, 577 a.p_xs, 578 {top: -4, right: -4}, 579 ]}> 580 {({ 581 hovered: dismissHovered, 582 pressed: dismissPressed, 583 }) => ( 584 <X 585 size="xs" 586 fill={ 587 dismissHovered || dismissPressed 588 ? t.atoms.text.color 589 : t.atoms.text_contrast_medium.color 590 } 591 /> 592 )} 593 </Button> 594 )} 595 <View 596 style={[ 597 a.flex_col, 598 a.align_center, 599 a.gap_sm, 600 a.pb_sm, 601 a.mb_auto, 602 ]}> 603 <ProfileCard.Avatar 604 profile={profile.actor} 605 moderationOpts={moderationOpts} 606 disabledPreview 607 size={88} 608 /> 609 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 610 <ProfileCard.Name 611 profile={profile.actor} 612 moderationOpts={moderationOpts} 613 /> 614 <ProfileCard.Description 615 profile={profile.actor} 616 numberOfLines={2} 617 style={[ 618 t.atoms.text_contrast_medium, 619 a.text_center, 620 a.text_xs, 621 ]} 622 /> 623 </View> 624 </View> 625 626 <ProfileCard.FollowButton 627 profile={profile.actor} 628 moderationOpts={moderationOpts} 629 logContext="FeedInterstitial" 630 withIcon={false} 631 style={[a.rounded_sm]} 632 onFollow={() => { 633 ax.metric('suggestedUser:follow', { 634 logContext: isFeedContext 635 ? 'InterstitialDiscover' 636 : 'InterstitialProfile', 637 location: 'Card', 638 recId: profile.recId, 639 position: index, 640 suggestedDid: profile.actor.did, 641 category: null, 642 }) 643 }} 644 /> 645 </ProfileCard.Outer> 646 </CardOuter> 647 )} 648 </ProfileCard.Link> 649 </Animated.View> 650 )) 651 652 // Use totalProfileCount (before dismissals) for minLength check on initial render. 653 const profileCountForMinCheck = totalProfileCount ?? profiles.length 654 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 655 ax.logger.debug(`Not enough profiles to show suggested follows`) 656 return null 657 } 658 659 return ( 660 <View 661 ref={containerRef} 662 style={[ 663 !isProfileHeaderContext && a.border_t, 664 t.atoms.border_contrast_low, 665 t.atoms.bg_contrast_25, 666 ]} 667 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 668 <View 669 style={[ 670 a.px_lg, 671 a.pt_md, 672 a.flex_row, 673 a.align_center, 674 a.justify_between, 675 ]} 676 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 677 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> 678 <Trans>Suggested for you</Trans> 679 </Text> 680 {!isProfileHeaderContext && ( 681 <Button 682 label={_(msg`See more suggested profiles`)} 683 onPress={() => { 684 followDialogControl.open() 685 ax.metric('suggestedUser:seeMore', { 686 logContext: isFeedContext ? 'Explore' : 'Profile', 687 }) 688 }}> 689 {({hovered}) => ( 690 <Text 691 style={[ 692 a.text_sm, 693 {color: t.palette.primary_500}, 694 hovered && 695 web({ 696 textDecorationLine: 'underline', 697 textDecorationColor: t.palette.primary_500, 698 }), 699 ]}> 700 <Trans>See more</Trans> 701 </Text> 702 )} 703 </Button> 704 )} 705 </View> 706 707 <FollowDialogWithoutGuide control={followDialogControl} /> 708 709 <LayoutAnimationConfig skipExiting skipEntering> 710 {gtMobile ? ( 711 <View style={[a.p_lg, a.pt_md]}> 712 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 713 {content} 714 </View> 715 </View> 716 ) : ( 717 <BlockDrawerGesture> 718 <ScrollView 719 horizontal 720 showsHorizontalScrollIndicator={false} 721 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 722 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 723 decelerationRate="fast"> 724 {content} 725 726 {!isProfileHeaderContext && ( 727 <SeeMoreSuggestedProfilesCard 728 onPress={() => { 729 followDialogControl.open() 730 ax.metric('suggestedUser:seeMore', { 731 logContext: 'Explore', 732 }) 733 }} 734 /> 735 )} 736 </ScrollView> 737 </BlockDrawerGesture> 738 )} 739 </LayoutAnimationConfig> 740 </View> 741 ) 742} 743 744function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 745 const {_} = useLingui() 746 747 return ( 748 <Button 749 label={_(msg`Browse more accounts`)} 750 onPress={onPress} 751 style={[ 752 a.flex_col, 753 a.align_center, 754 a.justify_center, 755 a.gap_sm, 756 a.p_md, 757 a.rounded_lg, 758 {width: FINAL_CARD_WIDTH}, 759 ]}> 760 <ButtonIcon icon={ArrowRight} size="lg" /> 761 <ButtonText 762 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> 763 <Trans>See more</Trans> 764 </ButtonText> 765 </Button> 766 ) 767} 768 769const numFeedsToDisplay = 3 770export function SuggestedFeeds() { 771 const t = useTheme() 772 const ax = useAnalytics() 773 const {_} = useLingui() 774 const {data, isLoading, error} = useGetPopularFeedsQuery({ 775 limit: numFeedsToDisplay, 776 }) 777 const navigation = useNavigation<NavigationProp>() 778 const {gtMobile} = useBreakpoints() 779 780 const feeds = useMemo(() => { 781 const items: AppBskyFeedDefs.GeneratorView[] = [] 782 783 if (!data) return items 784 785 for (const page of data.pages) { 786 for (const feed of page.feeds) { 787 items.push(feed) 788 } 789 } 790 791 return items 792 }, [data]) 793 794 const content = isLoading ? ( 795 Array(numFeedsToDisplay) 796 .fill(0) 797 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) 798 ) : error || !feeds ? null : ( 799 <> 800 {feeds.slice(0, numFeedsToDisplay).map(feed => ( 801 <FeedCard.Link 802 key={feed.uri} 803 view={feed} 804 onPress={() => { 805 ax.metric('feed:interstitial:feedCard:press', {}) 806 }}> 807 {({hovered, pressed}) => ( 808 <CardOuter 809 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 810 <FeedCard.Outer> 811 <FeedCard.Header> 812 <FeedCard.Avatar src={feed.avatar} /> 813 <FeedCard.TitleAndByline 814 title={feed.displayName} 815 creator={feed.creator} 816 uri={feed.uri} 817 /> 818 </FeedCard.Header> 819 <FeedCard.Description 820 description={feed.description} 821 numberOfLines={3} 822 /> 823 </FeedCard.Outer> 824 </CardOuter> 825 )} 826 </FeedCard.Link> 827 ))} 828 </> 829 ) 830 831 return error ? null : ( 832 <View 833 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 834 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> 835 <Text 836 style={[ 837 a.flex_1, 838 a.text_lg, 839 a.font_semi_bold, 840 t.atoms.text_contrast_medium, 841 ]}> 842 <Trans>Some other feeds you might like</Trans> 843 </Text> 844 <Hashtag fill={t.atoms.text_contrast_low.color} /> 845 </View> 846 847 {gtMobile ? ( 848 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> 849 {content} 850 851 <View 852 style={[ 853 a.flex_row, 854 a.justify_end, 855 a.align_center, 856 a.pt_xs, 857 a.gap_md, 858 ]}> 859 <InlineLinkText 860 label={_(msg`Browse more suggestions`)} 861 to="/search" 862 style={[t.atoms.text_contrast_medium]}> 863 <Trans>Browse more suggestions</Trans> 864 </InlineLinkText> 865 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> 866 </View> 867 </View> 868 ) : ( 869 <BlockDrawerGesture> 870 <ScrollView 871 horizontal 872 showsHorizontalScrollIndicator={false} 873 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 874 decelerationRate="fast"> 875 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> 876 {content} 877 878 <Button 879 label={_(msg`Browse more feeds on the Explore page`)} 880 onPress={() => { 881 navigation.navigate('SearchTab') 882 }} 883 style={[a.flex_col]}> 884 <CardOuter> 885 <View style={[a.flex_1, a.justify_center]}> 886 <View style={[a.flex_row, a.px_lg]}> 887 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 888 <Trans> 889 Browse more suggestions on the Explore page 890 </Trans> 891 </Text> 892 893 <ArrowRight size="xl" /> 894 </View> 895 </View> 896 </CardOuter> 897 </Button> 898 </View> 899 </ScrollView> 900 </BlockDrawerGesture> 901 )} 902 </View> 903 ) 904} 905 906export function ProgressGuide() { 907 const t = useTheme() 908 const {gtMobile} = useBreakpoints() 909 return ( 910 <View 911 style={[ 912 t.atoms.border_contrast_low, 913 a.px_lg, 914 a.py_lg, 915 !gtMobile && {marginTop: 4}, 916 ]}> 917 <ProgressGuideList /> 918 </View> 919 ) 920}