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