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

Configure Feed

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

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