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

Configure Feed

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

at f50b70e2b4db2a12fdfd13b662231a1b372d73ce 941 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 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 {_} = useLingui() 437 const gate = useGate() 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 const showDismissButton = onDismiss && gate('suggested_users_dismiss') 446 447 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 448 const minLength = gtMobile ? 3 : 4 449 450 // Track seen profiles 451 const seenProfilesRef = useRef<Set<string>>(new Set()) 452 const containerRef = useRef<View>(null) 453 const hasTrackedRef = useRef(false) 454 const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 455 isFeedContext 456 ? 'InterstitialDiscover' 457 : isProfileHeaderContext 458 ? 'Profile' 459 : 'InterstitialProfile' 460 461 // Callback to fire seen events 462 const fireSeen = useCallback(() => { 463 if (isLoading || error || !profiles.length) return 464 if (hasTrackedRef.current) return 465 hasTrackedRef.current = true 466 467 const profilesToShow = profiles.slice(0, maxLength) 468 profilesToShow.forEach((profile, index) => { 469 if (!seenProfilesRef.current.has(profile.did)) { 470 seenProfilesRef.current.add(profile.did) 471 logger.metric( 472 'suggestedUser:seen', 473 { 474 logContext, 475 recId, 476 position: index, 477 suggestedDid: profile.did, 478 category: null, 479 }, 480 {statsig: true}, 481 ) 482 } 483 }) 484 }, [isLoading, error, profiles, maxLength, logContext, recId]) 485 486 // For profile header, fire when isVisible becomes true 487 useEffect(() => { 488 if (isProfileHeaderContext) { 489 if (!isVisible) { 490 hasTrackedRef.current = false 491 return 492 } 493 fireSeen() 494 } 495 }, [isVisible, isProfileHeaderContext, fireSeen]) 496 497 // For feed interstitials, use IntersectionObserver to detect actual visibility 498 useEffect(() => { 499 if (isProfileHeaderContext) return // handled above 500 if (isLoading || error || !profiles.length) return 501 502 const node = containerRef.current 503 if (!node) return 504 505 // Use IntersectionObserver on web to detect when actually visible 506 if (typeof IntersectionObserver !== 'undefined') { 507 const observer = new IntersectionObserver( 508 entries => { 509 if (entries[0]?.isIntersecting) { 510 fireSeen() 511 observer.disconnect() 512 } 513 }, 514 {threshold: 0.5}, 515 ) 516 // @ts-ignore - web only 517 observer.observe(node) 518 return () => observer.disconnect() 519 } else { 520 // On native, delay slightly to account for layout shifts during hydration 521 const timeout = setTimeout(() => { 522 fireSeen() 523 }, 500) 524 return () => clearTimeout(timeout) 525 } 526 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 527 528 const content = isLoading 529 ? Array(maxLength) 530 .fill(0) 531 .map((_, i) => ( 532 <View 533 key={i} 534 style={[ 535 a.flex_1, 536 gtMobile && 537 web([ 538 a.flex_0, 539 a.flex_grow, 540 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 541 ]), 542 ]}> 543 <SuggestedFollowPlaceholder /> 544 </View> 545 )) 546 : error || !profiles.length 547 ? null 548 : profiles.slice(0, maxLength).map((profile, index) => ( 549 <Animated.View 550 key={profile.did} 551 layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)} 552 style={[ 553 a.flex_1, 554 gtMobile && 555 web([ 556 a.flex_0, 557 a.flex_grow, 558 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 559 ]), 560 { 561 opacity: dismissingDids?.has(profile.did) ? 0 : 1, 562 transitionProperty: 'opacity', 563 transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`, 564 }, 565 ]}> 566 <ProfileCard.Link 567 profile={profile} 568 onPress={() => { 569 logEvent('suggestedUser:press', { 570 logContext: isFeedContext 571 ? 'InterstitialDiscover' 572 : 'InterstitialProfile', 573 recId, 574 position: index, 575 suggestedDid: profile.did, 576 category: null, 577 }) 578 }}> 579 {({hovered, pressed}) => ( 580 <CardOuter 581 style={[ 582 (hovered || pressed) && t.atoms.border_contrast_high, 583 ]}> 584 <ProfileCard.Outer> 585 {showDismissButton && ( 586 <Button 587 label={_(msg`Dismiss this suggestion`)} 588 onPress={e => { 589 e.preventDefault() 590 onDismiss!(profile.did) 591 logEvent('suggestedUser:dismiss', { 592 logContext: isFeedContext 593 ? 'InterstitialDiscover' 594 : 'InterstitialProfile', 595 position: index, 596 suggestedDid: profile.did, 597 recId, 598 }) 599 }} 600 style={[ 601 a.absolute, 602 a.z_10, 603 a.p_xs, 604 {top: -4, right: -4}, 605 ]}> 606 {({ 607 hovered: dismissHovered, 608 pressed: dismissPressed, 609 }) => ( 610 <X 611 size="xs" 612 fill={ 613 dismissHovered || dismissPressed 614 ? t.atoms.text.color 615 : t.atoms.text_contrast_medium.color 616 } 617 /> 618 )} 619 </Button> 620 )} 621 <View 622 style={[ 623 a.flex_col, 624 a.align_center, 625 a.gap_sm, 626 a.pb_sm, 627 a.mb_auto, 628 ]}> 629 <ProfileCard.Avatar 630 profile={profile} 631 moderationOpts={moderationOpts} 632 disabledPreview 633 size={88} 634 /> 635 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 636 <ProfileCard.Name 637 profile={profile} 638 moderationOpts={moderationOpts} 639 /> 640 <ProfileCard.Description 641 profile={profile} 642 numberOfLines={2} 643 style={[ 644 t.atoms.text_contrast_medium, 645 a.text_center, 646 a.text_xs, 647 ]} 648 /> 649 </View> 650 </View> 651 652 <ProfileCard.FollowButton 653 profile={profile} 654 moderationOpts={moderationOpts} 655 logContext="FeedInterstitial" 656 withIcon={false} 657 style={[a.rounded_sm]} 658 onFollow={() => { 659 logEvent('suggestedUser:follow', { 660 logContext: isFeedContext 661 ? 'InterstitialDiscover' 662 : 'InterstitialProfile', 663 location: 'Card', 664 recId, 665 position: index, 666 suggestedDid: profile.did, 667 category: null, 668 }) 669 }} 670 /> 671 </ProfileCard.Outer> 672 </CardOuter> 673 )} 674 </ProfileCard.Link> 675 </Animated.View> 676 )) 677 678 // Use totalProfileCount (before dismissals) for minLength check on initial render. 679 const profileCountForMinCheck = totalProfileCount ?? profiles.length 680 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 681 logger.debug(`Not enough profiles to show suggested follows`) 682 return null 683 } 684 685 return ( 686 <View 687 ref={containerRef} 688 style={[ 689 !isProfileHeaderContext && a.border_t, 690 t.atoms.border_contrast_low, 691 t.atoms.bg_contrast_25, 692 ]} 693 pointerEvents={isIOS ? 'auto' : 'box-none'}> 694 <View 695 style={[ 696 a.px_lg, 697 a.pt_md, 698 a.flex_row, 699 a.align_center, 700 a.justify_between, 701 ]} 702 pointerEvents={isIOS ? 'auto' : 'box-none'}> 703 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> 704 {isFeedContext ? ( 705 <Trans>Suggested for you</Trans> 706 ) : ( 707 <Trans>Similar accounts</Trans> 708 )} 709 </Text> 710 {!isProfileHeaderContext && ( 711 <Button 712 label={_(msg`See more suggested profiles`)} 713 onPress={() => { 714 followDialogControl.open() 715 logEvent('suggestedUser:seeMore', { 716 logContext: isFeedContext ? 'Explore' : 'Profile', 717 }) 718 }}> 719 {({hovered}) => ( 720 <Text 721 style={[ 722 a.text_sm, 723 {color: t.palette.primary_500}, 724 hovered && 725 web({ 726 textDecorationLine: 'underline', 727 textDecorationColor: t.palette.primary_500, 728 }), 729 ]}> 730 <Trans>See more</Trans> 731 </Text> 732 )} 733 </Button> 734 )} 735 </View> 736 737 <FollowDialogWithoutGuide control={followDialogControl} /> 738 739 {gtMobile ? ( 740 <View style={[a.p_lg, a.pt_md]}> 741 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 742 {content} 743 </View> 744 </View> 745 ) : ( 746 <BlockDrawerGesture> 747 <ScrollView 748 horizontal 749 showsHorizontalScrollIndicator={false} 750 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 751 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 752 decelerationRate="fast"> 753 {content} 754 755 {!isProfileHeaderContext && ( 756 <SeeMoreSuggestedProfilesCard 757 onPress={() => { 758 followDialogControl.open() 759 logger.metric('suggestedUser:seeMore', { 760 logContext: 'Explore', 761 }) 762 }} 763 /> 764 )} 765 </ScrollView> 766 </BlockDrawerGesture> 767 )} 768 </View> 769 ) 770} 771 772function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 773 const t = useTheme() 774 const {_} = useLingui() 775 776 return ( 777 <Button 778 label={_(msg`Browse more accounts`)} 779 onPress={onPress} 780 style={[ 781 a.flex_col, 782 a.align_center, 783 a.justify_center, 784 a.gap_sm, 785 a.p_md, 786 a.rounded_lg, 787 t.atoms.shadow_sm, 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 /> 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 return ( 937 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}> 938 <ProgressGuideList /> 939 </View> 940 ) 941}