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

Configure Feed

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

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