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

Configure Feed

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

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