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