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

Configure Feed

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

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