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

Configure Feed

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

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