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 715 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 isSuggestionsLoading={isLoading} 172 profiles={filteredProfiles} 173 totalProfileCount={allProfiles.length} 174 error={error} 175 viewContext="feed" 176 onDismiss={onDismiss} 177 /> 178 ) 179} 180 181export function ProfileGrid({ 182 isSuggestionsLoading, 183 error, 184 profiles, 185 recId, 186 totalProfileCount, 187 viewContext = 'feed', 188 onDismiss, 189 isVisible = true, 190 onRequestHide, 191}: { 192 isSuggestionsLoading: boolean 193 profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[] 194 recId?: string 195 totalProfileCount?: number 196 error: Error | null 197 viewContext: 'profile' | 'profileHeader' | 'feed' 198 onDismiss?: (did: string) => void 199 isVisible?: boolean 200 onRequestHide?: () => void 201}) { 202 const t = useTheme() 203 const ax = useAnalytics() 204 const {t: l} = useLingui() 205 const moderationOpts = useModerationOpts() 206 const {gtMobile} = useBreakpoints() 207 const followDialogControl = useDialogControl() 208 209 const isLoading = isSuggestionsLoading || !moderationOpts 210 const isProfileHeaderContext = viewContext === 'profileHeader' 211 const isFeedContext = viewContext === 'feed' 212 213 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 214 const minLength = gtMobile ? 3 : 4 215 216 // hide similar accounts 217 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() 218 219 // Track seen profiles 220 const seenProfilesRef = useRef<Set<string>>(new Set()) 221 const containerRef = useRef<View>(null) 222 const hasTrackedRef = useRef(false) 223 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext 224 ? 'DiscoverInterstitial' 225 : isProfileHeaderContext 226 ? 'ProfileHeader' 227 : 'ProfileInterstitial' 228 229 // Callback to fire seen events 230 const fireSeen = useCallback(() => { 231 if (isLoading || error || !profiles.length) return 232 if (hasTrackedRef.current) return 233 hasTrackedRef.current = true 234 235 const profilesToShow = profiles.slice(0, maxLength) 236 profilesToShow.forEach((profile, index) => { 237 if (!seenProfilesRef.current.has(profile.actor.did)) { 238 seenProfilesRef.current.add(profile.actor.did) 239 ax.metric('suggestedUser:seen', { 240 logContext, 241 recId: profile.recId, 242 position: index, 243 suggestedDid: profile.actor.did, 244 category: null, 245 }) 246 } 247 }) 248 }, [isLoading, error, profiles, maxLength, ax, logContext]) 249 250 // For profile header, fire when isVisible becomes true 251 useEffect(() => { 252 if (isProfileHeaderContext) { 253 if (!isVisible) { 254 hasTrackedRef.current = false 255 return 256 } 257 fireSeen() 258 } 259 }, [isVisible, isProfileHeaderContext, fireSeen]) 260 261 // For feed interstitials, use IntersectionObserver to detect actual visibility 262 useEffect(() => { 263 if (isProfileHeaderContext) return // handled above 264 if (isLoading || error || !profiles.length) return 265 266 const node = containerRef.current 267 if (!node) return 268 269 // Use IntersectionObserver on web to detect when actually visible 270 if (typeof IntersectionObserver !== 'undefined') { 271 const observer = new IntersectionObserver( 272 entries => { 273 if (entries[0]?.isIntersecting) { 274 fireSeen() 275 observer.disconnect() 276 } 277 }, 278 {threshold: 0.5}, 279 ) 280 // @ts-ignore - web only 281 observer.observe(node) 282 return () => observer.disconnect() 283 } else { 284 // On native, delay slightly to account for layout shifts during hydration 285 const timeout = setTimeout(() => { 286 fireSeen() 287 }, 500) 288 return () => clearTimeout(timeout) 289 } 290 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 291 292 const content = isLoading 293 ? Array(maxLength) 294 .fill(0) 295 .map((_, i) => ( 296 <View 297 key={i} 298 style={[ 299 a.flex_1, 300 gtMobile && 301 web([ 302 a.flex_0, 303 a.flex_grow, 304 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 305 ]), 306 ]}> 307 <SuggestedFollowPlaceholder /> 308 </View> 309 )) 310 : error || !profiles.length 311 ? null 312 : profiles.slice(0, maxLength).map((profile, index) => ( 313 <Animated.View 314 key={profile.actor.did} 315 layout={native( 316 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing( 317 Easing.out(Easing.exp), 318 ), 319 )} 320 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)} 321 // for web, as the cards are static, not in a list 322 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))} 323 style={[ 324 a.flex_1, 325 gtMobile && 326 web([ 327 a.flex_0, 328 a.flex_grow, 329 {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 330 ]), 331 ]}> 332 <ProfileCard.Link 333 profile={profile.actor} 334 onPress={() => { 335 ax.metric('suggestedUser:press', { 336 logContext, 337 recId: profile.recId, 338 position: index, 339 suggestedDid: profile.actor.did, 340 category: null, 341 }) 342 }} 343 style={[a.flex_1]}> 344 {({hovered, pressed}) => ( 345 <CardOuter 346 style={[ 347 (hovered || pressed) && t.atoms.border_contrast_high, 348 ]}> 349 <ProfileCard.Outer> 350 {onDismiss && ( 351 <Button 352 label={l`Dismiss this suggestion`} 353 onPress={e => { 354 e.preventDefault() 355 onDismiss(profile.actor.did) 356 ax.metric('suggestedUser:dismiss', { 357 logContext, 358 position: index, 359 suggestedDid: profile.actor.did, 360 recId: profile.recId, 361 }) 362 }} 363 style={[ 364 a.absolute, 365 a.z_10, 366 a.p_xs, 367 {top: -4, right: -4}, 368 ]}> 369 {({ 370 hovered: dismissHovered, 371 pressed: dismissPressed, 372 }) => ( 373 <X 374 size="xs" 375 fill={ 376 dismissHovered || dismissPressed 377 ? t.atoms.text.color 378 : t.atoms.text_contrast_medium.color 379 } 380 /> 381 )} 382 </Button> 383 )} 384 <View 385 style={[ 386 a.flex_col, 387 a.align_center, 388 a.gap_sm, 389 a.pb_sm, 390 a.mb_auto, 391 ]}> 392 <ProfileCard.Avatar 393 profile={profile.actor} 394 moderationOpts={moderationOpts} 395 disabledPreview 396 size={88} 397 /> 398 <View style={[a.flex_col, a.align_center, a.max_w_full]}> 399 <ProfileCard.Name 400 profile={profile.actor} 401 moderationOpts={moderationOpts} 402 /> 403 <ProfileCard.Description 404 profile={profile.actor} 405 numberOfLines={2} 406 style={[ 407 t.atoms.text_contrast_medium, 408 a.text_center, 409 a.text_xs, 410 ]} 411 /> 412 </View> 413 </View> 414 415 <ProfileCard.FollowButton 416 profile={profile.actor} 417 moderationOpts={moderationOpts} 418 logContext="FeedInterstitial" 419 withIcon={false} 420 style={[a.rounded_sm]} 421 onFollow={() => { 422 ax.metric('suggestedUser:follow', { 423 logContext, 424 location: 'Profile', 425 recId: profile.recId, 426 position: index, 427 suggestedDid: profile.actor.did, 428 category: null, 429 }) 430 }} 431 /> 432 </ProfileCard.Outer> 433 </CardOuter> 434 )} 435 </ProfileCard.Link> 436 </Animated.View> 437 )) 438 439 // Use totalProfileCount (before dismissals) for minLength check on initial render. 440 const profileCountForMinCheck = totalProfileCount ?? profiles.length 441 442 useEffect(() => { 443 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 444 onRequestHide?.() 445 } 446 }, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength]) 447 448 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 449 ax.logger.debug(`Not enough profiles to show suggested follows`) 450 return null 451 } 452 453 if (!hideSimilarAccountsRecomm) { 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={IS_IOS ? '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={IS_IOS ? '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 <Button 480 label={l`See more suggested profiles`} 481 onPress={() => { 482 followDialogControl.open() 483 ax.metric('suggestedUser:seeMore', { 484 logContext, 485 recId, 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 </View> 504 <FollowDialogWithoutGuide control={followDialogControl} /> 505 <LayoutAnimationConfig skipExiting skipEntering> 506 {gtMobile ? ( 507 <View style={[a.p_lg, a.pt_md]}> 508 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> 509 {content} 510 </View> 511 </View> 512 ) : ( 513 <BlockDrawerGesture> 514 <ScrollView 515 horizontal 516 showsHorizontalScrollIndicator={false} 517 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 518 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 519 decelerationRate="fast"> 520 {content} 521 522 <SeeMoreSuggestedProfilesCard 523 onPress={() => { 524 followDialogControl.open() 525 ax.metric('suggestedUser:seeMore', { 526 logContext, 527 }) 528 }} 529 /> 530 </ScrollView> 531 </BlockDrawerGesture> 532 )} 533 </LayoutAnimationConfig> 534 </View> 535 ) 536 } 537} 538 539function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { 540 const {t: l} = useLingui() 541 542 return ( 543 <Button 544 label={l`Browse more accounts`} 545 onPress={onPress} 546 style={[ 547 a.flex_col, 548 a.align_center, 549 a.justify_center, 550 a.gap_sm, 551 a.p_md, 552 a.rounded_lg, 553 {width: FINAL_CARD_WIDTH}, 554 ]}> 555 <ButtonIcon icon={ArrowRight} size="lg" /> 556 <ButtonText 557 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> 558 <Trans>See more</Trans> 559 </ButtonText> 560 </Button> 561 ) 562} 563 564const numFeedsToDisplay = 3 565export function SuggestedFeeds() { 566 const t = useTheme() 567 const ax = useAnalytics() 568 const {t: l} = useLingui() 569 const {data, isLoading, error} = useGetPopularFeedsQuery({ 570 limit: numFeedsToDisplay, 571 }) 572 const navigation = useNavigation<NavigationProp>() 573 const {gtMobile} = useBreakpoints() 574 575 const feeds = useMemo(() => { 576 const items: AppBskyFeedDefs.GeneratorView[] = [] 577 578 if (!data) return items 579 580 for (const page of data.pages) { 581 for (const feed of page.feeds) { 582 items.push(feed) 583 } 584 } 585 586 return items 587 }, [data]) 588 589 const content = isLoading ? ( 590 Array(numFeedsToDisplay) 591 .fill(0) 592 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) 593 ) : error || !feeds ? null : ( 594 <> 595 {feeds.slice(0, numFeedsToDisplay).map(feed => ( 596 <FeedCard.Link 597 key={feed.uri} 598 view={feed} 599 onPress={() => { 600 ax.metric('feed:interstitial:feedCard:press', {}) 601 }}> 602 {({hovered, pressed}) => ( 603 <CardOuter 604 style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 605 <FeedCard.Outer> 606 <FeedCard.Header> 607 <FeedCard.Avatar src={feed.avatar} /> 608 <FeedCard.TitleAndByline 609 title={feed.displayName} 610 creator={feed.creator} 611 uri={feed.uri} 612 /> 613 </FeedCard.Header> 614 <FeedCard.Description 615 description={feed.description} 616 numberOfLines={3} 617 /> 618 </FeedCard.Outer> 619 </CardOuter> 620 )} 621 </FeedCard.Link> 622 ))} 623 </> 624 ) 625 626 return error ? null : ( 627 <View 628 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 629 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> 630 <Text 631 style={[ 632 a.flex_1, 633 a.text_lg, 634 a.font_semi_bold, 635 t.atoms.text_contrast_medium, 636 ]}> 637 <Trans>Some other feeds you might like</Trans> 638 </Text> 639 <Hashtag fill={t.atoms.text_contrast_low.color} /> 640 </View> 641 642 {gtMobile ? ( 643 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> 644 {content} 645 646 <View 647 style={[ 648 a.flex_row, 649 a.justify_end, 650 a.align_center, 651 a.pt_xs, 652 a.gap_md, 653 ]}> 654 <InlineLinkText 655 label={l`Browse more suggestions`} 656 to="/search" 657 style={[t.atoms.text_contrast_medium]}> 658 <Trans>Browse more suggestions</Trans> 659 </InlineLinkText> 660 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> 661 </View> 662 </View> 663 ) : ( 664 <BlockDrawerGesture> 665 <ScrollView 666 horizontal 667 showsHorizontalScrollIndicator={false} 668 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 669 decelerationRate="fast"> 670 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> 671 {content} 672 673 <Button 674 label={l`Browse more feeds on the Explore page`} 675 onPress={() => { 676 navigation.navigate('SearchTab') 677 }} 678 style={[a.flex_col]}> 679 <CardOuter> 680 <View style={[a.flex_1, a.justify_center]}> 681 <View style={[a.flex_row, a.px_lg]}> 682 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> 683 <Trans> 684 Browse more suggestions on the Explore page 685 </Trans> 686 </Text> 687 688 <ArrowRight size="xl" /> 689 </View> 690 </View> 691 </CardOuter> 692 </Button> 693 </View> 694 </ScrollView> 695 </BlockDrawerGesture> 696 )} 697 </View> 698 ) 699} 700 701export function ProgressGuide() { 702 const t = useTheme() 703 const {gtMobile} = useBreakpoints() 704 return ( 705 <View 706 style={[ 707 t.atoms.border_contrast_low, 708 a.px_lg, 709 a.py_lg, 710 !gtMobile && {marginTop: 4}, 711 ]}> 712 <ProgressGuideList /> 713 </View> 714 ) 715}