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

Configure Feed

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

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