Bluesky app fork with some witchin' additions 馃挮
at main 22 kB view raw
1import React, {useCallback, useEffect, useRef} from 'react' 2import {ScrollView, View} from 'react-native' 3import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6import {useNavigation} from '@react-navigation/native' 7 8import {type NavigationProp} from '#/lib/routes/types' 9import {logEvent} from '#/lib/statsig/statsig' 10import {logger} from '#/logger' 11import {type MetricEvents} from '#/logger/metrics' 12import {isIOS} from '#/platform/detection' 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 {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 19import {useSession} from '#/state/session' 20import * as userActionHistory from '#/state/userActionHistory' 21import {type SeenPost} from '#/state/userActionHistory' 22import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 23import { 24 atoms as a, 25 useBreakpoints, 26 useTheme, 27 type ViewStyleProp, 28 web, 29} from '#/alf' 30import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31import {useDialogControl} from '#/components/Dialog' 32import * as FeedCard from '#/components/FeedCard' 33import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 34import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 35import {InlineLinkText} from '#/components/Link' 36import * as ProfileCard from '#/components/ProfileCard' 37import {Text} from '#/components/Typography' 38import type * as bsky from '#/types/bsky' 39import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' 40import {ProgressGuideList} from './ProgressGuide/List' 41 42const MOBILE_CARD_WIDTH = 165 43const FINAL_CARD_WIDTH = 120 44 45function CardOuter({ 46 children, 47 style, 48}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 49 const t = useTheme() 50 const {gtMobile} = useBreakpoints() 51 return ( 52 <View 53 style={[ 54 a.flex_1, 55 a.w_full, 56 a.p_md, 57 a.rounded_lg, 58 a.border, 59 t.atoms.bg, 60 t.atoms.shadow_sm, 61 t.atoms.border_contrast_low, 62 !gtMobile && { 63 width: MOBILE_CARD_WIDTH, 64 }, 65 style, 66 ]}> 67 {children} 68 </View> 69 ) 70} 71 72export function SuggestedFollowPlaceholder() { 73 return ( 74 <CardOuter> 75 <ProfileCard.Outer> 76 <View 77 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> 78 <ProfileCard.AvatarPlaceholder size={88} /> 79 <ProfileCard.NamePlaceholder /> 80 <View style={[a.w_full]}> 81 <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> 82 </View> 83 </View> 84 85 <ProfileCard.FollowButtonPlaceholder /> 86 </ProfileCard.Outer> 87 </CardOuter> 88 ) 89} 90 91export function SuggestedFeedsCardPlaceholder() { 92 return ( 93 <CardOuter style={[a.gap_sm]}> 94 <FeedCard.Header> 95 <FeedCard.AvatarPlaceholder /> 96 <FeedCard.TitleAndBylinePlaceholder creator /> 97 </FeedCard.Header> 98 99 <FeedCard.DescriptionPlaceholder /> 100 </CardOuter> 101 ) 102} 103 104function getRank(seenPost: SeenPost): string { 105 let tier: string 106 if (seenPost.feedContext === 'popfriends') { 107 tier = 'a' 108 } else if (seenPost.feedContext?.startsWith('cluster')) { 109 tier = 'b' 110 } else if (seenPost.feedContext === 'popcluster') { 111 tier = 'c' 112 } else if (seenPost.feedContext?.startsWith('ntpc')) { 113 tier = 'd' 114 } else if (seenPost.feedContext?.startsWith('t-')) { 115 tier = 'e' 116 } else if (seenPost.feedContext === 'nettop') { 117 tier = 'f' 118 } else { 119 tier = 'g' 120 } 121 let score = Math.round( 122 Math.log( 123 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount, 124 ), 125 ) 126 if (seenPost.isFollowedBy || Math.random() > 0.9) { 127 score *= 2 128 } 129 const rank = 100 - score 130 return `${tier}-${rank}` 131} 132 133function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 { 134 const rankA = getRank(postA) 135 const rankB = getRank(postB) 136 // Yes, we're comparing strings here. 137 // The "larger" string means a worse rank. 138 if (rankA > rankB) { 139 return 1 140 } else if (rankA < rankB) { 141 return -1 142 } else { 143 return 0 144 } 145} 146 147function useExperimentalSuggestedUsersQuery() { 148 const {currentAccount} = useSession() 149 const userActionSnapshot = userActionHistory.useActionHistorySnapshot() 150 const dids = React.useMemo(() => { 151 const {likes, follows, followSuggestions, seen} = userActionSnapshot 152 const likeDids = likes 153 .map(l => new AtUri(l)) 154 .map(uri => uri.host) 155 .filter(did => !follows.includes(did)) 156 let suggestedDids: string[] = [] 157 if (followSuggestions.length > 0) { 158 suggestedDids = [ 159 // It's ok if these will pick the same item (weighed by its frequency) 160 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 161 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 162 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 163 followSuggestions[Math.floor(Math.random() * followSuggestions.length)], 164 ] 165 } 166 const seenDids = seen 167 .sort(sortSeenPosts) 168 .map(l => new AtUri(l.uri)) 169 .map(uri => uri.host) 170 return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( 171 did => did !== currentAccount?.did, 172 ) 173 }, [userActionSnapshot, currentAccount]) 174 const {data, isLoading, error} = useProfilesQuery({ 175 handles: dids.slice(0, 16), 176 }) 177 178 const profiles = data 179 ? data.profiles.filter(profile => { 180 return !profile.viewer?.following 181 }) 182 : [] 183 184 return { 185 isLoading, 186 error, 187 profiles: profiles.slice(0, 6), 188 } 189} 190 191export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { 192 const {currentAccount} = useSession() 193 const [feedType, feedUriOrDid] = feed.split('|') 194 if (feedType === 'author') { 195 if (currentAccount?.did === feedUriOrDid) { 196 return null 197 } else { 198 return <SuggestedFollowsProfile did={feedUriOrDid} /> 199 } 200 } else { 201 return <SuggestedFollowsHome /> 202 } 203} 204 205export function SuggestedFollowsProfile({did}: {did: string}) { 206 const { 207 isLoading: isSuggestionsLoading, 208 data, 209 error, 210 } = useSuggestedFollowsByActorQuery({ 211 did, 212 }) 213 return ( 214 <ProfileGrid 215 isSuggestionsLoading={isSuggestionsLoading} 216 profiles={data?.suggestions ?? []} 217 recId={data?.recId} 218 error={error} 219 viewContext="profile" 220 /> 221 ) 222} 223 224export function SuggestedFollowsHome() { 225 const { 226 isLoading: isSuggestionsLoading, 227 profiles, 228 error, 229 } = useExperimentalSuggestedUsersQuery() 230 return ( 231 <ProfileGrid 232 isSuggestionsLoading={isSuggestionsLoading} 233 profiles={profiles} 234 error={error} 235 viewContext="feed" 236 /> 237 ) 238} 239 240export function ProfileGrid({ 241 isSuggestionsLoading, 242 error, 243 profiles, 244 recId, 245 viewContext = 'feed', 246 isVisible = true, 247}: { 248 isSuggestionsLoading: boolean 249 profiles: bsky.profile.AnyProfileView[] 250 recId?: number 251 error: Error | null 252 viewContext: 'profile' | 'profileHeader' | 'feed' 253 isVisible?: boolean 254}) { 255 const t = useTheme() 256 const {_} = useLingui() 257 const moderationOpts = useModerationOpts() 258 const {gtMobile} = useBreakpoints() 259 const followDialogControl = useDialogControl() 260 261 const isLoading = isSuggestionsLoading || !moderationOpts 262 const isProfileHeaderContext = viewContext === 'profileHeader' 263 const isFeedContext = viewContext === 'feed' 264 265 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 266 const minLength = gtMobile ? 3 : 4 267 268 // hide similar accounts 269 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() 270 271 // Track seen profiles 272 const seenProfilesRef = useRef<Set<string>>(new Set()) 273 const containerRef = useRef<View>(null) 274 const hasTrackedRef = useRef(false) 275 const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 276 isFeedContext 277 ? 'InterstitialDiscover' 278 : isProfileHeaderContext 279 ? 'Profile' 280 : 'InterstitialProfile' 281 282 // Callback to fire seen events 283 const fireSeen = useCallback(() => { 284 if (isLoading || error || !profiles.length) return 285 if (hasTrackedRef.current) return 286 hasTrackedRef.current = true 287 288 const profilesToShow = profiles.slice(0, maxLength) 289 profilesToShow.forEach((profile, index) => { 290 if (!seenProfilesRef.current.has(profile.did)) { 291 seenProfilesRef.current.add(profile.did) 292 logger.metric( 293 'suggestedUser:seen', 294 { 295 logContext, 296 recId, 297 position: index, 298 suggestedDid: profile.did, 299 category: null, 300 }, 301 {statsig: true}, 302 ) 303 } 304 }) 305 }, [isLoading, error, profiles, maxLength, logContext, recId]) 306 307 // For profile header, fire when isVisible becomes true 308 useEffect(() => { 309 if (isProfileHeaderContext) { 310 if (!isVisible) { 311 hasTrackedRef.current = false 312 return 313 } 314 fireSeen() 315 } 316 }, [isVisible, isProfileHeaderContext, fireSeen]) 317 318 // For feed interstitials, use IntersectionObserver to detect actual visibility 319 useEffect(() => { 320 if (isProfileHeaderContext) return // handled above 321 if (isLoading || error || !profiles.length) return 322 323 const node = containerRef.current 324 if (!node) return 325 326 // Use IntersectionObserver on web to detect when actually visible 327 if (typeof IntersectionObserver !== 'undefined') { 328 const observer = new IntersectionObserver( 329 entries => { 330 if (entries[0]?.isIntersecting) { 331 fireSeen() 332 observer.disconnect() 333 } 334 }, 335 {threshold: 0.5}, 336 ) 337 // @ts-ignore - web only 338 observer.observe(node) 339 return () => observer.disconnect() 340 } else { 341 // On native, delay slightly to account for layout shifts during hydration 342 const timeout = setTimeout(() => { 343 fireSeen() 344 }, 500) 345 return () => clearTimeout(timeout) 346 } 347 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 348 349 const content = isLoading 350 ? Array(maxLength) 351 .fill(0) 352 .map((_, i) => ( 353 <View 354 key={i} 355 style={[ 356 a.flex_1, 357 gtMobile && 358 web([ 359 a.flex_0, 360 a.flex_grow, 361 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 362 ]), 363 ]}> 364 <SuggestedFollowPlaceholder /> 365 </View> 366 )) 367 : error || !profiles.length 368 ? null 369 : profiles.slice(0, maxLength).map((profile, index) => ( 370 <ProfileCard.Link 371 key={profile.did} 372 profile={profile} 373 onPress={() => { 374 logEvent('suggestedUser:press', { 375 logContext: isFeedContext 376 ? 'InterstitialDiscover' 377 : 'InterstitialProfile', 378 recId, 379 position: index, 380 suggestedDid: profile.did, 381 category: null, 382 }) 383 }} 384 style={[ 385 a.flex_1, 386 gtMobile && 387 web([ 388 a.flex_0, 389 a.flex_grow, 390 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 391 ]), 392 ]}> 393 {({hovered, pressed}) => ( 394 <CardOuter 395 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 396 <ProfileCard.Outer> 397 <View 398 style={[ 399 a.flex_col, 400 a.align_center, 401 a.gap_sm, 402 a.pb_sm, 403 a.mb_auto, 404 ]}> 405 <ProfileCard.Avatar 406 profile={profile} 407 moderationOpts={moderationOpts} 408 disabledPreview 409 size={88} 410 /> 411 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 412 <ProfileCard.Name 413 profile={profile} 414 moderationOpts={moderationOpts} 415 /> 416 <ProfileCard.Description 417 profile={profile} 418 numberOfLines={2} 419 style={[ 420 t.atoms.text_contrast_medium, 421 a.text_center, 422 a.text_xs, 423 ]} 424 /> 425 </View> 426 </View> 427 428 <ProfileCard.FollowButton 429 profile={profile} 430 moderationOpts={moderationOpts} 431 logContext="FeedInterstitial" 432 withIcon={false} 433 style={[a.rounded_sm]} 434 onFollow={() => { 435 logEvent('suggestedUser:follow', { 436 logContext: isFeedContext 437 ? 'InterstitialDiscover' 438 : 'InterstitialProfile', 439 location: 'Card', 440 recId, 441 position: index, 442 suggestedDid: profile.did, 443 category: null, 444 }) 445 }} 446 /> 447 </ProfileCard.Outer> 448 </CardOuter> 449 )} 450 </ProfileCard.Link> 451 )) 452 453 if (error || (!isLoading && profiles.length < minLength)) { 454 logger.debug(`Not enough profiles to show suggested follows`) 455 return null 456 } 457 458 if (!hideSimilarAccountsRecomm) { 459 return ( 460 <View 461 ref={containerRef} 462 style={[ 463 !isProfileHeaderContext && a.border_t, 464 t.atoms.border_contrast_low, 465 t.atoms.bg_contrast_25, 466 ]} 467 pointerEvents={isIOS ? 'auto' : 'box-none'}> 468 <View 469 style={[ 470 a.px_lg, 471 a.pt_md, 472 a.flex_row, 473 a.align_center, 474 a.justify_between, 475 ]} 476 pointerEvents={isIOS ? 'auto' : 'box-none'}> 477 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}> 478 {isFeedContext ? ( 479 <Trans>Suggested for you</Trans> 480 ) : ( 481 <Trans>Similar accounts</Trans> 482 )} 483 </Text> 484 {!isProfileHeaderContext && ( 485 <Button 486 label={_(msg`See more suggested profiles`)} 487 onPress={() => { 488 followDialogControl.open() 489 logEvent('suggestedUser:seeMore', { 490 logContext: isFeedContext ? 'Explore' : 'Profile', 491 }) 492 }}> 493 {({hovered}) => ( 494 <Text 495 style={[ 496 a.text_sm, 497 {color: t.palette.primary_500}, 498 hovered && 499 web({ 500 textDecorationLine: 'underline', 501 textDecorationColor: t.palette.primary_500, 502 }), 503 ]}> 504 <Trans>See more</Trans> 505 </Text> 506 )} 507 </Button> 508 )} 509 </View> 510 511 <FollowDialogWithoutGuide control={followDialogControl} /> 512 513 {gtMobile ? ( 514 <View style={[a.p_lg, a.pt_md]}> 515 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 516 {content} 517 </View> 518 </View> 519 ) : ( 520 <BlockDrawerGesture> 521 <ScrollView 522 horizontal 523 showsHorizontalScrollIndicator={false} 524 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 525 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 526 decelerationRate="fast"> 527 {content} 528 529 {!isProfileHeaderContext && ( 530 <SeeMoreSuggestedProfilesCard 531 onPress={() => { 532 followDialogControl.open() 533 logger.metric('suggestedUser:seeMore', { 534 logContext: 'Explore', 535 }) 536 }} 537 /> 538 )} 539 </ScrollView> 540 </BlockDrawerGesture> 541 )} 542 </View> 543 ) 544 } 545} 546 547function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 548 const t = useTheme() 549 const {_} = useLingui() 550 551 return ( 552 <Button 553 label={_(msg`Browse more accounts`)} 554 onPress={onPress} 555 style={[ 556 a.flex_col, 557 a.align_center, 558 a.justify_center, 559 a.gap_sm, 560 a.p_md, 561 a.rounded_lg, 562 t.atoms.shadow_sm, 563 {width: FINAL_CARD_WIDTH}, 564 ]}> 565 <ButtonIcon icon={ArrowRight} size="lg" /> 566 <ButtonText 567 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> 568 <Trans>See more</Trans> 569 </ButtonText> 570 </Button> 571 ) 572} 573 574export function SuggestedFeeds() { 575 const numFeedsToDisplay = 3 576 const t = useTheme() 577 const {_} = useLingui() 578 const {data, isLoading, error} = useGetPopularFeedsQuery({ 579 limit: numFeedsToDisplay, 580 }) 581 const navigation = useNavigation<NavigationProp>() 582 const {gtMobile} = useBreakpoints() 583 584 const feeds = React.useMemo(() => { 585 const items: AppBskyFeedDefs.GeneratorView[] = [] 586 587 if (!data) return items 588 589 for (const page of data.pages) { 590 for (const feed of page.feeds) { 591 items.push(feed) 592 } 593 } 594 595 return items 596 }, [data]) 597 598 const content = isLoading ? ( 599 Array(numFeedsToDisplay) 600 .fill(0) 601 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) 602 ) : error || !feeds ? null : ( 603 <> 604 {feeds.slice(0, numFeedsToDisplay).map(feed => ( 605 <FeedCard.Link 606 key={feed.uri} 607 view={feed} 608 onPress={() => { 609 logEvent('feed:interstitial:feedCard:press', {}) 610 }}> 611 {({hovered, pressed}) => ( 612 <CardOuter 613 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 614 <FeedCard.Outer> 615 <FeedCard.Header> 616 <FeedCard.Avatar src={feed.avatar} /> 617 <FeedCard.TitleAndByline 618 title={feed.displayName} 619 creator={feed.creator} 620 /> 621 </FeedCard.Header> 622 <FeedCard.Description 623 description={feed.description} 624 numberOfLines={3} 625 /> 626 </FeedCard.Outer> 627 </CardOuter> 628 )} 629 </FeedCard.Link> 630 ))} 631 </> 632 ) 633 634 return error ? null : ( 635 <View 636 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 637 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> 638 <Text 639 style={[ 640 a.flex_1, 641 a.text_lg, 642 a.font_semi_bold, 643 t.atoms.text_contrast_medium, 644 ]}> 645 <Trans>Some other feeds you might like</Trans> 646 </Text> 647 <Hashtag fill={t.atoms.text_contrast_low.color} /> 648 </View> 649 650 {gtMobile ? ( 651 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> 652 {content} 653 654 <View 655 style={[ 656 a.flex_row, 657 a.justify_end, 658 a.align_center, 659 a.pt_xs, 660 a.gap_md, 661 ]}> 662 <InlineLinkText 663 label={_(msg`Browse more suggestions`)} 664 to="/search" 665 style={[t.atoms.text_contrast_medium]}> 666 <Trans>Browse more suggestions</Trans> 667 </InlineLinkText> 668 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> 669 </View> 670 </View> 671 ) : ( 672 <BlockDrawerGesture> 673 <ScrollView 674 horizontal 675 showsHorizontalScrollIndicator={false} 676 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 677 decelerationRate="fast"> 678 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> 679 {content} 680 681 <Button 682 label={_(msg`Browse more feeds on the Explore page`)} 683 onPress={() => { 684 navigation.navigate('SearchTab') 685 }} 686 style={[a.flex_col]}> 687 <CardOuter> 688 <View style={[a.flex_1, a.justify_center]}> 689 <View style={[a.flex_row, a.px_lg]}> 690 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 691 <Trans> 692 Browse more suggestions on the Explore page 693 </Trans> 694 </Text> 695 696 <ArrowRight size="xl" /> 697 </View> 698 </View> 699 </CardOuter> 700 </Button> 701 </View> 702 </ScrollView> 703 </BlockDrawerGesture> 704 )} 705 </View> 706 ) 707} 708 709export function ProgressGuide() { 710 const t = useTheme() 711 return ( 712 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}> 713 <ProgressGuideList /> 714 </View> 715 ) 716}