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

Configure Feed

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

at post-text-option 945 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 {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations' 15import {useModerationOpts} from '#/state/preferences/moderation-opts' 16import {useGetPopularFeedsQuery} from '#/state/queries/feed' 17import {type FeedDescriptor} from '#/state/queries/post-feed' 18import {useProfilesQuery} from '#/state/queries/profile' 19import { 20 useSuggestedFollowsByActorQuery, 21 useSuggestedFollowsQuery, 22} from '#/state/queries/suggested-follows' 23import {useSession} from '#/state/session' 24import * as userActionHistory from '#/state/userActionHistory' 25import {type SeenPost} from '#/state/userActionHistory' 26import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 27import { 28 atoms as a, 29 useBreakpoints, 30 useTheme, 31 type ViewStyleProp, 32 web, 33} from '#/alf' 34import {Button, ButtonIcon, ButtonText} from '#/components/Button' 35import {useDialogControl} from '#/components/Dialog' 36import * as FeedCard from '#/components/FeedCard' 37import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 38import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 39import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 40import {InlineLinkText} from '#/components/Link' 41import * as ProfileCard from '#/components/ProfileCard' 42import {Text} from '#/components/Typography' 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 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 // hide similar accounts 452 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() 453 454 // Track seen profiles 455 const seenProfilesRef = useRef<Set<string>>(new Set()) 456 const containerRef = useRef<View>(null) 457 const hasTrackedRef = useRef(false) 458 const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 459 isFeedContext 460 ? 'InterstitialDiscover' 461 : isProfileHeaderContext 462 ? 'Profile' 463 : 'InterstitialProfile' 464 465 // Callback to fire seen events 466 const fireSeen = useCallback(() => { 467 if (isLoading || error || !profiles.length) return 468 if (hasTrackedRef.current) return 469 hasTrackedRef.current = true 470 471 const profilesToShow = profiles.slice(0, maxLength) 472 profilesToShow.forEach((profile, index) => { 473 if (!seenProfilesRef.current.has(profile.did)) { 474 seenProfilesRef.current.add(profile.did) 475 logger.metric( 476 'suggestedUser:seen', 477 { 478 logContext, 479 recId, 480 position: index, 481 suggestedDid: profile.did, 482 category: null, 483 }, 484 {statsig: true}, 485 ) 486 } 487 }) 488 }, [isLoading, error, profiles, maxLength, logContext, recId]) 489 490 // For profile header, fire when isVisible becomes true 491 useEffect(() => { 492 if (isProfileHeaderContext) { 493 if (!isVisible) { 494 hasTrackedRef.current = false 495 return 496 } 497 fireSeen() 498 } 499 }, [isVisible, isProfileHeaderContext, fireSeen]) 500 501 // For feed interstitials, use IntersectionObserver to detect actual visibility 502 useEffect(() => { 503 if (isProfileHeaderContext) return // handled above 504 if (isLoading || error || !profiles.length) return 505 506 const node = containerRef.current 507 if (!node) return 508 509 // Use IntersectionObserver on web to detect when actually visible 510 if (typeof IntersectionObserver !== 'undefined') { 511 const observer = new IntersectionObserver( 512 entries => { 513 if (entries[0]?.isIntersecting) { 514 fireSeen() 515 observer.disconnect() 516 } 517 }, 518 {threshold: 0.5}, 519 ) 520 // @ts-ignore - web only 521 observer.observe(node) 522 return () => observer.disconnect() 523 } else { 524 // On native, delay slightly to account for layout shifts during hydration 525 const timeout = setTimeout(() => { 526 fireSeen() 527 }, 500) 528 return () => clearTimeout(timeout) 529 } 530 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 531 532 const content = isLoading 533 ? Array(maxLength) 534 .fill(0) 535 .map((_, i) => ( 536 <View 537 key={i} 538 style={[ 539 a.flex_1, 540 gtMobile && 541 web([ 542 a.flex_0, 543 a.flex_grow, 544 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 545 ]), 546 ]}> 547 <SuggestedFollowPlaceholder /> 548 </View> 549 )) 550 : error || !profiles.length 551 ? null 552 : profiles.slice(0, maxLength).map((profile, index) => ( 553 <Animated.View 554 key={profile.did} 555 layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)} 556 style={[ 557 a.flex_1, 558 gtMobile && 559 web([ 560 a.flex_0, 561 a.flex_grow, 562 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 563 ]), 564 { 565 opacity: dismissingDids?.has(profile.did) ? 0 : 1, 566 transitionProperty: 'opacity', 567 transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`, 568 }, 569 ]}> 570 <ProfileCard.Link 571 profile={profile} 572 onPress={() => { 573 logEvent('suggestedUser:press', { 574 logContext: isFeedContext 575 ? 'InterstitialDiscover' 576 : 'InterstitialProfile', 577 recId, 578 position: index, 579 suggestedDid: profile.did, 580 category: null, 581 }) 582 }}> 583 {({hovered, pressed}) => ( 584 <CardOuter 585 style={[ 586 (hovered || pressed) && t.atoms.border_contrast_high, 587 ]}> 588 <ProfileCard.Outer> 589 {showDismissButton && ( 590 <Button 591 label={_(msg`Dismiss this suggestion`)} 592 onPress={e => { 593 e.preventDefault() 594 onDismiss!(profile.did) 595 logEvent('suggestedUser:dismiss', { 596 logContext: isFeedContext 597 ? 'InterstitialDiscover' 598 : 'InterstitialProfile', 599 position: index, 600 suggestedDid: profile.did, 601 recId, 602 }) 603 }} 604 style={[ 605 a.absolute, 606 a.z_10, 607 a.p_xs, 608 {top: -4, right: -4}, 609 ]}> 610 {({ 611 hovered: dismissHovered, 612 pressed: dismissPressed, 613 }) => ( 614 <X 615 size="xs" 616 fill={ 617 dismissHovered || dismissPressed 618 ? t.atoms.text.color 619 : t.atoms.text_contrast_medium.color 620 } 621 /> 622 )} 623 </Button> 624 )} 625 <View 626 style={[ 627 a.flex_col, 628 a.align_center, 629 a.gap_sm, 630 a.pb_sm, 631 a.mb_auto, 632 ]}> 633 <ProfileCard.Avatar 634 profile={profile} 635 moderationOpts={moderationOpts} 636 disabledPreview 637 size={88} 638 /> 639 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 640 <ProfileCard.Name 641 profile={profile} 642 moderationOpts={moderationOpts} 643 /> 644 <ProfileCard.Description 645 profile={profile} 646 numberOfLines={2} 647 style={[ 648 t.atoms.text_contrast_medium, 649 a.text_center, 650 a.text_xs, 651 ]} 652 /> 653 </View> 654 </View> 655 656 <ProfileCard.FollowButton 657 profile={profile} 658 moderationOpts={moderationOpts} 659 logContext="FeedInterstitial" 660 withIcon={false} 661 style={[a.rounded_sm]} 662 onFollow={() => { 663 logEvent('suggestedUser:follow', { 664 logContext: isFeedContext 665 ? 'InterstitialDiscover' 666 : 'InterstitialProfile', 667 location: 'Card', 668 recId, 669 position: index, 670 suggestedDid: profile.did, 671 category: null, 672 }) 673 }} 674 /> 675 </ProfileCard.Outer> 676 </CardOuter> 677 )} 678 </ProfileCard.Link> 679 </Animated.View> 680 )) 681 682 // Use totalProfileCount (before dismissals) for minLength check on initial render. 683 const profileCountForMinCheck = totalProfileCount ?? profiles.length 684 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 685 logger.debug(`Not enough profiles to show suggested follows`) 686 return null 687 } 688 689 if (!hideSimilarAccountsRecomm) { 690 return ( 691 <View 692 ref={containerRef} 693 style={[ 694 !isProfileHeaderContext && a.border_t, 695 t.atoms.border_contrast_low, 696 t.atoms.bg_contrast_25, 697 ]} 698 pointerEvents={isIOS ? 'auto' : 'box-none'}> 699 <View 700 style={[ 701 a.px_lg, 702 a.pt_md, 703 a.flex_row, 704 a.align_center, 705 a.justify_between, 706 ]} 707 pointerEvents={isIOS ? 'auto' : 'box-none'}> 708 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> 709 {isFeedContext ? ( 710 <Trans>Suggested for you</Trans> 711 ) : ( 712 <Trans>Similar accounts</Trans> 713 )} 714 </Text> 715 {!isProfileHeaderContext && ( 716 <Button 717 label={_(msg`See more suggested profiles`)} 718 onPress={() => { 719 followDialogControl.open() 720 logEvent('suggestedUser:seeMore', { 721 logContext: isFeedContext ? 'Explore' : 'Profile', 722 }) 723 }}> 724 {({hovered}) => ( 725 <Text 726 style={[ 727 a.text_sm, 728 {color: t.palette.primary_500}, 729 hovered && 730 web({ 731 textDecorationLine: 'underline', 732 textDecorationColor: t.palette.primary_500, 733 }), 734 ]}> 735 <Trans>See more</Trans> 736 </Text> 737 )} 738 </Button> 739 )} 740 </View> 741 742 <FollowDialogWithoutGuide control={followDialogControl} /> 743 744 {gtMobile ? ( 745 <View style={[a.p_lg, a.pt_md]}> 746 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 747 {content} 748 </View> 749 </View> 750 ) : ( 751 <BlockDrawerGesture> 752 <ScrollView 753 horizontal 754 showsHorizontalScrollIndicator={false} 755 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 756 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 757 decelerationRate="fast"> 758 {content} 759 760 {!isProfileHeaderContext && ( 761 <SeeMoreSuggestedProfilesCard 762 onPress={() => { 763 followDialogControl.open() 764 logger.metric('suggestedUser:seeMore', { 765 logContext: 'Explore', 766 }) 767 }} 768 /> 769 )} 770 </ScrollView> 771 </BlockDrawerGesture> 772 )} 773 </View> 774 ) 775 } 776} 777 778function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 779 const {_} = useLingui() 780 781 return ( 782 <Button 783 label={_(msg`Browse more accounts`)} 784 onPress={onPress} 785 style={[ 786 a.flex_col, 787 a.align_center, 788 a.justify_center, 789 a.gap_sm, 790 a.p_md, 791 a.rounded_lg, 792 {width: FINAL_CARD_WIDTH}, 793 ]}> 794 <ButtonIcon icon={ArrowRight} size="lg" /> 795 <ButtonText 796 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> 797 <Trans>See more</Trans> 798 </ButtonText> 799 </Button> 800 ) 801} 802 803export function SuggestedFeeds() { 804 const numFeedsToDisplay = 3 805 const t = useTheme() 806 const {_} = useLingui() 807 const {data, isLoading, error} = useGetPopularFeedsQuery({ 808 limit: numFeedsToDisplay, 809 }) 810 const navigation = useNavigation<NavigationProp>() 811 const {gtMobile} = useBreakpoints() 812 813 const feeds = React.useMemo(() => { 814 const items: AppBskyFeedDefs.GeneratorView[] = [] 815 816 if (!data) return items 817 818 for (const page of data.pages) { 819 for (const feed of page.feeds) { 820 items.push(feed) 821 } 822 } 823 824 return items 825 }, [data]) 826 827 const content = isLoading ? ( 828 Array(numFeedsToDisplay) 829 .fill(0) 830 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) 831 ) : error || !feeds ? null : ( 832 <> 833 {feeds.slice(0, numFeedsToDisplay).map(feed => ( 834 <FeedCard.Link 835 key={feed.uri} 836 view={feed} 837 onPress={() => { 838 logEvent('feed:interstitial:feedCard:press', {}) 839 }}> 840 {({hovered, pressed}) => ( 841 <CardOuter 842 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 843 <FeedCard.Outer> 844 <FeedCard.Header> 845 <FeedCard.Avatar src={feed.avatar} /> 846 <FeedCard.TitleAndByline 847 title={feed.displayName} 848 creator={feed.creator} 849 /> 850 </FeedCard.Header> 851 <FeedCard.Description 852 description={feed.description} 853 numberOfLines={3} 854 /> 855 </FeedCard.Outer> 856 </CardOuter> 857 )} 858 </FeedCard.Link> 859 ))} 860 </> 861 ) 862 863 return error ? null : ( 864 <View 865 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 866 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> 867 <Text 868 style={[ 869 a.flex_1, 870 a.text_lg, 871 a.font_semi_bold, 872 t.atoms.text_contrast_medium, 873 ]}> 874 <Trans>Some other feeds you might like</Trans> 875 </Text> 876 <Hashtag fill={t.atoms.text_contrast_low.color} /> 877 </View> 878 879 {gtMobile ? ( 880 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> 881 {content} 882 883 <View 884 style={[ 885 a.flex_row, 886 a.justify_end, 887 a.align_center, 888 a.pt_xs, 889 a.gap_md, 890 ]}> 891 <InlineLinkText 892 label={_(msg`Browse more suggestions`)} 893 to="/search" 894 style={[t.atoms.text_contrast_medium]}> 895 <Trans>Browse more suggestions</Trans> 896 </InlineLinkText> 897 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> 898 </View> 899 </View> 900 ) : ( 901 <BlockDrawerGesture> 902 <ScrollView 903 horizontal 904 showsHorizontalScrollIndicator={false} 905 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 906 decelerationRate="fast"> 907 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> 908 {content} 909 910 <Button 911 label={_(msg`Browse more feeds on the Explore page`)} 912 onPress={() => { 913 navigation.navigate('SearchTab') 914 }} 915 style={[a.flex_col]}> 916 <CardOuter> 917 <View style={[a.flex_1, a.justify_center]}> 918 <View style={[a.flex_row, a.px_lg]}> 919 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 920 <Trans> 921 Browse more suggestions on the Explore page 922 </Trans> 923 </Text> 924 925 <ArrowRight size="xl" /> 926 </View> 927 </View> 928 </CardOuter> 929 </Button> 930 </View> 931 </ScrollView> 932 </BlockDrawerGesture> 933 )} 934 </View> 935 ) 936} 937 938export function ProgressGuide() { 939 const t = useTheme() 940 return ( 941 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}> 942 <ProgressGuideList /> 943 </View> 944 ) 945}