Bluesky app fork with some witchin' additions 馃挮
at main 929 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 {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations' 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 // hide similar accounts 427 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() 428 429 // Track seen profiles 430 const seenProfilesRef = useRef<Set<string>>(new Set()) 431 const containerRef = useRef<View>(null) 432 const hasTrackedRef = useRef(false) 433 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext 434 ? 'InterstitialDiscover' 435 : isProfileHeaderContext 436 ? 'Profile' 437 : 'InterstitialProfile' 438 439 // Callback to fire seen events 440 const fireSeen = useCallback(() => { 441 if (isLoading || error || !profiles.length) return 442 if (hasTrackedRef.current) return 443 hasTrackedRef.current = true 444 445 const profilesToShow = profiles.slice(0, maxLength) 446 profilesToShow.forEach((profile, index) => { 447 if (!seenProfilesRef.current.has(profile.actor.did)) { 448 seenProfilesRef.current.add(profile.actor.did) 449 ax.metric('suggestedUser:seen', { 450 logContext, 451 recId: profile.recId, 452 position: index, 453 suggestedDid: profile.actor.did, 454 category: null, 455 }) 456 } 457 }) 458 }, [ax, isLoading, error, profiles, maxLength, logContext]) 459 460 // For profile header, fire when isVisible becomes true 461 useEffect(() => { 462 if (isProfileHeaderContext) { 463 if (!isVisible) { 464 hasTrackedRef.current = false 465 return 466 } 467 fireSeen() 468 } 469 }, [isVisible, isProfileHeaderContext, fireSeen]) 470 471 // For feed interstitials, use IntersectionObserver to detect actual visibility 472 useEffect(() => { 473 if (isProfileHeaderContext) return // handled above 474 if (isLoading || error || !profiles.length) return 475 476 const node = containerRef.current 477 if (!node) return 478 479 // Use IntersectionObserver on web to detect when actually visible 480 if (typeof IntersectionObserver !== 'undefined') { 481 const observer = new IntersectionObserver( 482 entries => { 483 if (entries[0]?.isIntersecting) { 484 fireSeen() 485 observer.disconnect() 486 } 487 }, 488 {threshold: 0.5}, 489 ) 490 // @ts-ignore - web only 491 observer.observe(node) 492 return () => observer.disconnect() 493 } else { 494 // On native, delay slightly to account for layout shifts during hydration 495 const timeout = setTimeout(() => { 496 fireSeen() 497 }, 500) 498 return () => clearTimeout(timeout) 499 } 500 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 501 502 const content = isLoading 503 ? Array(maxLength) 504 .fill(0) 505 .map((_, i) => ( 506 <View 507 key={i} 508 style={[ 509 a.flex_1, 510 gtMobile && 511 web([ 512 a.flex_0, 513 a.flex_grow, 514 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 515 ]), 516 ]}> 517 <SuggestedFollowPlaceholder /> 518 </View> 519 )) 520 : error || !profiles.length 521 ? null 522 : profiles.slice(0, maxLength).map((profile, index) => ( 523 <Animated.View 524 key={profile.actor.did} 525 layout={native( 526 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing( 527 Easing.out(Easing.exp), 528 ), 529 )} 530 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)} 531 // for web, as the cards are static, not in a list 532 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))} 533 style={[ 534 a.flex_1, 535 gtMobile && 536 web([ 537 a.flex_0, 538 a.flex_grow, 539 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 540 ]), 541 ]}> 542 <ProfileCard.Link 543 profile={profile.actor} 544 onPress={() => { 545 ax.metric('suggestedUser:press', { 546 logContext: isFeedContext 547 ? 'InterstitialDiscover' 548 : 'InterstitialProfile', 549 recId: profile.recId, 550 position: index, 551 suggestedDid: profile.actor.did, 552 category: null, 553 }) 554 }} 555 style={[a.flex_1]}> 556 {({hovered, pressed}) => ( 557 <CardOuter 558 style={[ 559 (hovered || pressed) && t.atoms.border_contrast_high, 560 ]}> 561 <ProfileCard.Outer> 562 {onDismiss && ( 563 <Button 564 label={_(msg`Dismiss this suggestion`)} 565 onPress={e => { 566 e.preventDefault() 567 onDismiss(profile.actor.did) 568 ax.metric('suggestedUser:dismiss', { 569 logContext: isFeedContext 570 ? 'InterstitialDiscover' 571 : 'InterstitialProfile', 572 position: index, 573 suggestedDid: profile.actor.did, 574 recId: profile.recId, 575 }) 576 }} 577 style={[ 578 a.absolute, 579 a.z_10, 580 a.p_xs, 581 {top: -4, right: -4}, 582 ]}> 583 {({ 584 hovered: dismissHovered, 585 pressed: dismissPressed, 586 }) => ( 587 <X 588 size="xs" 589 fill={ 590 dismissHovered || dismissPressed 591 ? t.atoms.text.color 592 : t.atoms.text_contrast_medium.color 593 } 594 /> 595 )} 596 </Button> 597 )} 598 <View 599 style={[ 600 a.flex_col, 601 a.align_center, 602 a.gap_sm, 603 a.pb_sm, 604 a.mb_auto, 605 ]}> 606 <ProfileCard.Avatar 607 profile={profile.actor} 608 moderationOpts={moderationOpts} 609 disabledPreview 610 size={88} 611 /> 612 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 613 <ProfileCard.Name 614 profile={profile.actor} 615 moderationOpts={moderationOpts} 616 /> 617 <ProfileCard.Description 618 profile={profile.actor} 619 numberOfLines={2} 620 style={[ 621 t.atoms.text_contrast_medium, 622 a.text_center, 623 a.text_xs, 624 ]} 625 /> 626 </View> 627 </View> 628 629 <ProfileCard.FollowButton 630 profile={profile.actor} 631 moderationOpts={moderationOpts} 632 logContext="FeedInterstitial" 633 withIcon={false} 634 style={[a.rounded_sm]} 635 onFollow={() => { 636 ax.metric('suggestedUser:follow', { 637 logContext: isFeedContext 638 ? 'InterstitialDiscover' 639 : 'InterstitialProfile', 640 location: 'Card', 641 recId: profile.recId, 642 position: index, 643 suggestedDid: profile.actor.did, 644 category: null, 645 }) 646 }} 647 /> 648 </ProfileCard.Outer> 649 </CardOuter> 650 )} 651 </ProfileCard.Link> 652 </Animated.View> 653 )) 654 655 // Use totalProfileCount (before dismissals) for minLength check on initial render. 656 const profileCountForMinCheck = totalProfileCount ?? profiles.length 657 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 658 ax.logger.debug(`Not enough profiles to show suggested follows`) 659 return null 660 } 661 662 if (!hideSimilarAccountsRecomm) { 663 return ( 664 <View 665 ref={containerRef} 666 style={[ 667 !isProfileHeaderContext && a.border_t, 668 t.atoms.border_contrast_low, 669 t.atoms.bg_contrast_25, 670 ]} 671 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 672 <View 673 style={[ 674 a.px_lg, 675 a.pt_md, 676 a.flex_row, 677 a.align_center, 678 a.justify_between, 679 ]} 680 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 681 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> 682 {isFeedContext ? ( 683 <Trans>Suggested for you</Trans> 684 ) : ( 685 <Trans>Similar accounts</Trans> 686 )} 687 </Text> 688 {!isProfileHeaderContext && ( 689 <Button 690 label={_(msg`See more suggested profiles`)} 691 onPress={() => { 692 followDialogControl.open() 693 ax.metric('suggestedUser:seeMore', { 694 logContext: isFeedContext ? 'Explore' : 'Profile', 695 }) 696 }}> 697 {({hovered}) => ( 698 <Text 699 style={[ 700 a.text_sm, 701 {color: t.palette.primary_500}, 702 hovered && 703 web({ 704 textDecorationLine: 'underline', 705 textDecorationColor: t.palette.primary_500, 706 }), 707 ]}> 708 <Trans>See more</Trans> 709 </Text> 710 )} 711 </Button> 712 )} 713 </View> 714 715 <FollowDialogWithoutGuide control={followDialogControl} /> 716 717 <LayoutAnimationConfig skipExiting skipEntering> 718 {gtMobile ? ( 719 <View style={[a.p_lg, a.pt_md]}> 720 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 721 {content} 722 </View> 723 </View> 724 ) : ( 725 <BlockDrawerGesture> 726 <ScrollView 727 horizontal 728 showsHorizontalScrollIndicator={false} 729 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 730 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 731 decelerationRate="fast"> 732 {content} 733 734 {!isProfileHeaderContext && ( 735 <SeeMoreSuggestedProfilesCard 736 onPress={() => { 737 followDialogControl.open() 738 ax.metric('suggestedUser:seeMore', { 739 logContext: 'Explore', 740 }) 741 }} 742 /> 743 )} 744 </ScrollView> 745 </BlockDrawerGesture> 746 )} 747 </LayoutAnimationConfig> 748 </View> 749 ) 750 } 751} 752 753function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 754 const {_} = useLingui() 755 756 return ( 757 <Button 758 label={_(msg`Browse more accounts`)} 759 onPress={onPress} 760 style={[ 761 a.flex_col, 762 a.align_center, 763 a.justify_center, 764 a.gap_sm, 765 a.p_md, 766 a.rounded_lg, 767 {width: FINAL_CARD_WIDTH}, 768 ]}> 769 <ButtonIcon icon={ArrowRight} size="lg" /> 770 <ButtonText 771 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> 772 <Trans>See more</Trans> 773 </ButtonText> 774 </Button> 775 ) 776} 777 778const numFeedsToDisplay = 3 779export function SuggestedFeeds() { 780 const t = useTheme() 781 const ax = useAnalytics() 782 const {_} = useLingui() 783 const {data, isLoading, error} = useGetPopularFeedsQuery({ 784 limit: numFeedsToDisplay, 785 }) 786 const navigation = useNavigation<NavigationProp>() 787 const {gtMobile} = useBreakpoints() 788 789 const feeds = useMemo(() => { 790 const items: AppBskyFeedDefs.GeneratorView[] = [] 791 792 if (!data) return items 793 794 for (const page of data.pages) { 795 for (const feed of page.feeds) { 796 items.push(feed) 797 } 798 } 799 800 return items 801 }, [data]) 802 803 const content = isLoading ? ( 804 Array(numFeedsToDisplay) 805 .fill(0) 806 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) 807 ) : error || !feeds ? null : ( 808 <> 809 {feeds.slice(0, numFeedsToDisplay).map(feed => ( 810 <FeedCard.Link 811 key={feed.uri} 812 view={feed} 813 onPress={() => { 814 ax.metric('feed:interstitial:feedCard:press', {}) 815 }}> 816 {({hovered, pressed}) => ( 817 <CardOuter 818 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 819 <FeedCard.Outer> 820 <FeedCard.Header> 821 <FeedCard.Avatar src={feed.avatar} /> 822 <FeedCard.TitleAndByline 823 title={feed.displayName} 824 creator={feed.creator} 825 uri={feed.uri} 826 /> 827 </FeedCard.Header> 828 <FeedCard.Description 829 description={feed.description} 830 numberOfLines={3} 831 /> 832 </FeedCard.Outer> 833 </CardOuter> 834 )} 835 </FeedCard.Link> 836 ))} 837 </> 838 ) 839 840 return error ? null : ( 841 <View 842 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 843 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> 844 <Text 845 style={[ 846 a.flex_1, 847 a.text_lg, 848 a.font_semi_bold, 849 t.atoms.text_contrast_medium, 850 ]}> 851 <Trans>Some other feeds you might like</Trans> 852 </Text> 853 <Hashtag fill={t.atoms.text_contrast_low.color} /> 854 </View> 855 856 {gtMobile ? ( 857 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> 858 {content} 859 860 <View 861 style={[ 862 a.flex_row, 863 a.justify_end, 864 a.align_center, 865 a.pt_xs, 866 a.gap_md, 867 ]}> 868 <InlineLinkText 869 label={_(msg`Browse more suggestions`)} 870 to="/search" 871 style={[t.atoms.text_contrast_medium]}> 872 <Trans>Browse more suggestions</Trans> 873 </InlineLinkText> 874 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> 875 </View> 876 </View> 877 ) : ( 878 <BlockDrawerGesture> 879 <ScrollView 880 horizontal 881 showsHorizontalScrollIndicator={false} 882 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 883 decelerationRate="fast"> 884 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> 885 {content} 886 887 <Button 888 label={_(msg`Browse more feeds on the Explore page`)} 889 onPress={() => { 890 navigation.navigate('SearchTab') 891 }} 892 style={[a.flex_col]}> 893 <CardOuter> 894 <View style={[a.flex_1, a.justify_center]}> 895 <View style={[a.flex_row, a.px_lg]}> 896 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 897 <Trans> 898 Browse more suggestions on the Explore page 899 </Trans> 900 </Text> 901 902 <ArrowRight size="xl" /> 903 </View> 904 </View> 905 </CardOuter> 906 </Button> 907 </View> 908 </ScrollView> 909 </BlockDrawerGesture> 910 )} 911 </View> 912 ) 913} 914 915export function ProgressGuide() { 916 const t = useTheme() 917 const {gtMobile} = useBreakpoints() 918 return ( 919 <View 920 style={[ 921 t.atoms.border_contrast_low, 922 a.px_lg, 923 a.py_lg, 924 !gtMobile && {marginTop: 4}, 925 ]}> 926 <ProgressGuideList /> 927 </View> 928 ) 929}