import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {ScrollView, View} from 'react-native' import Animated, { Easing, FadeIn, FadeOut, LayoutAnimationConfig, LinearTransition, } from 'react-native-reanimated' import {type AppBskyFeedDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' import {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {type FeedDescriptor} from '#/state/queries/post-feed' import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows' import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery' import {useSession} from '#/state/session' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import { atoms as a, native, useBreakpoints, useTheme, type ViewStyleProp, web, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import * as FeedCard from '#/components/FeedCard' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {ProgressGuideList} from '#/components/ProgressGuide/List' import {Text} from '#/components/Typography' import {type Metrics, useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import type * as bsky from '#/types/bsky' import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' const DISMISS_ANIMATION_DURATION = 200 const MOBILE_CARD_WIDTH = 165 const FINAL_CARD_WIDTH = 120 function CardOuter({ children, style, }: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { const t = useTheme() const {gtMobile} = useBreakpoints() return ( {children} ) } export function SuggestedFollowPlaceholder() { return ( ) } export function SuggestedFeedsCardPlaceholder() { return ( ) } export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { const {currentAccount} = useSession() const [feedType, feedUriOrDid] = feed.split('|') if (feedType === 'author') { if (currentAccount?.did === feedUriOrDid) { return null } else { return } } else { return } } export function SuggestedFollowsProfile({did}: {did: string}) { const {profiles, recId, onDismiss, isLoading, error} = useSuggestedFollowsByActorWithDismiss({did}) return ( ) } export function SuggestedFollowsHome() { const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery() const profiles = data?.actors const [dismissedDids, setDismissedDids] = useState>(new Set()) const onDismiss = useCallback((did: string) => { setDismissedDids(prev => new Set(prev).add(did)) }, []) const allProfiles = useMemo(() => { const result: Array<{ actor: bsky.profile.AnyProfileView recId?: string }> = [] for (const profile of profiles ?? []) { result.push({actor: profile, recId: data?.recId}) } return result }, [data?.recId, profiles]) const filteredProfiles = useMemo(() => { return allProfiles.filter(p => !dismissedDids.has(p.actor.did)) }, [allProfiles, dismissedDids]) return ( ) } export function ProfileGrid({ isSuggestionsLoading, error, profiles, recId, totalProfileCount, viewContext = 'feed', onDismiss, isVisible = true, onRequestHide, }: { isSuggestionsLoading: boolean profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[] recId?: string totalProfileCount?: number error: Error | null viewContext: 'profile' | 'profileHeader' | 'feed' onDismiss?: (did: string) => void isVisible?: boolean onRequestHide?: () => void }) { const t = useTheme() const ax = useAnalytics() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() const followDialogControl = useDialogControl() const isLoading = isSuggestionsLoading || !moderationOpts const isProfileHeaderContext = viewContext === 'profileHeader' const isFeedContext = viewContext === 'feed' const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 const minLength = gtMobile ? 3 : 4 // hide similar accounts const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() // Track seen profiles const seenProfilesRef = useRef>(new Set()) const containerRef = useRef(null) const hasTrackedRef = useRef(false) const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext ? 'DiscoverInterstitial' : isProfileHeaderContext ? 'ProfileHeader' : 'ProfileInterstitial' // Callback to fire seen events const fireSeen = useCallback(() => { if (isLoading || error || !profiles.length) return if (hasTrackedRef.current) return hasTrackedRef.current = true const profilesToShow = profiles.slice(0, maxLength) profilesToShow.forEach((profile, index) => { if (!seenProfilesRef.current.has(profile.actor.did)) { seenProfilesRef.current.add(profile.actor.did) ax.metric('suggestedUser:seen', { logContext, recId: profile.recId, position: index, suggestedDid: profile.actor.did, category: null, }) } }) }, [isLoading, error, profiles, maxLength, ax, logContext]) // For profile header, fire when isVisible becomes true useEffect(() => { if (isProfileHeaderContext) { if (!isVisible) { hasTrackedRef.current = false return } fireSeen() } }, [isVisible, isProfileHeaderContext, fireSeen]) // For feed interstitials, use IntersectionObserver to detect actual visibility useEffect(() => { if (isProfileHeaderContext) return // handled above if (isLoading || error || !profiles.length) return const node = containerRef.current if (!node) return // Use IntersectionObserver on web to detect when actually visible if (typeof IntersectionObserver !== 'undefined') { const observer = new IntersectionObserver( entries => { if (entries[0]?.isIntersecting) { fireSeen() observer.disconnect() } }, {threshold: 0.5}, ) // @ts-ignore - web only observer.observe(node) return () => observer.disconnect() } else { // On native, delay slightly to account for layout shifts during hydration const timeout = setTimeout(() => { fireSeen() }, 500) return () => clearTimeout(timeout) } }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) const content = isLoading ? Array(maxLength) .fill(0) .map((_, i) => ( )) : error || !profiles.length ? null : profiles.slice(0, maxLength).map((profile, index) => ( { ax.metric('suggestedUser:press', { logContext, recId: profile.recId, position: index, suggestedDid: profile.actor.did, category: null, }) }} style={[a.flex_1]}> {({hovered, pressed}) => ( {onDismiss && ( )} { ax.metric('suggestedUser:follow', { logContext, location: 'Profile', recId: profile.recId, position: index, suggestedDid: profile.actor.did, category: null, }) }} /> )} )) // Use totalProfileCount (before dismissals) for minLength check on initial render. const profileCountForMinCheck = totalProfileCount ?? profiles.length useEffect(() => { if (error || (!isLoading && profileCountForMinCheck < minLength)) { onRequestHide?.() } }, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength]) if (error || (!isLoading && profileCountForMinCheck < minLength)) { ax.logger.debug(`Not enough profiles to show suggested follows`) return null } if (!hideSimilarAccountsRecomm) { return ( {isFeedContext ? ( Suggested for you ) : ( Similar accounts )} {gtMobile ? ( {content} ) : ( {content} { followDialogControl.open() ax.metric('suggestedUser:seeMore', { logContext, }) }} /> )} ) } } function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { const {t: l} = useLingui() return ( ) } const numFeedsToDisplay = 3 export function SuggestedFeeds() { const t = useTheme() const ax = useAnalytics() const {t: l} = useLingui() const {data, isLoading, error} = useGetPopularFeedsQuery({ limit: numFeedsToDisplay, }) const navigation = useNavigation() const {gtMobile} = useBreakpoints() const feeds = useMemo(() => { const items: AppBskyFeedDefs.GeneratorView[] = [] if (!data) return items for (const page of data.pages) { for (const feed of page.feeds) { items.push(feed) } } return items }, [data]) const content = isLoading ? ( Array(numFeedsToDisplay) .fill(0) .map((_, i) => ) ) : error || !feeds ? null : ( <> {feeds.slice(0, numFeedsToDisplay).map(feed => ( { ax.metric('feed:interstitial:feedCard:press', {}) }}> {({hovered, pressed}) => ( )} ))} ) return error ? null : ( Some other feeds you might like {gtMobile ? ( {content} Browse more suggestions ) : ( {content} )} ) } export function ProgressGuide() { const t = useTheme() const {gtMobile} = useBreakpoints() return ( ) }