import React, {useCallback, useEffect, useRef} from 'react' import {ScrollView, View} from 'react-native' import Animated, {LinearTransition} from 'react-native-reanimated' import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' 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 {useProfilesQuery} from '#/state/queries/profile' import { useSuggestedFollowsByActorQuery, useSuggestedFollowsQuery, } from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' import {type SeenPost} from '#/state/userActionHistory' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import { atoms as a, 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 {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' import {ProgressGuideList} from './ProgressGuide/List' 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 ( ) } function getRank(seenPost: SeenPost): string { let tier: string if (seenPost.feedContext === 'popfriends') { tier = 'a' } else if (seenPost.feedContext?.startsWith('cluster')) { tier = 'b' } else if (seenPost.feedContext === 'popcluster') { tier = 'c' } else if (seenPost.feedContext?.startsWith('ntpc')) { tier = 'd' } else if (seenPost.feedContext?.startsWith('t-')) { tier = 'e' } else if (seenPost.feedContext === 'nettop') { tier = 'f' } else { tier = 'g' } let score = Math.round( Math.log( 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount, ), ) if (seenPost.isFollowedBy || Math.random() > 0.9) { score *= 2 } const rank = 100 - score return `${tier}-${rank}` } function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 { const rankA = getRank(postA) const rankB = getRank(postB) // Yes, we're comparing strings here. // The "larger" string means a worse rank. if (rankA > rankB) { return 1 } else if (rankA < rankB) { return -1 } else { return 0 } } function useExperimentalSuggestedUsersQuery() { const {currentAccount} = useSession() const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const dids = React.useMemo(() => { const {likes, follows, followSuggestions, seen} = userActionSnapshot const likeDids = likes .map(l => new AtUri(l)) .map(uri => uri.host) .filter(did => !follows.includes(did)) let suggestedDids: string[] = [] if (followSuggestions.length > 0) { suggestedDids = [ // It's ok if these will pick the same item (weighed by its frequency) followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)], followSuggestions[Math.floor(Math.random() * followSuggestions.length)], ] } const seenDids = seen .sort(sortSeenPosts) .map(l => new AtUri(l.uri)) .map(uri => uri.host) return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( did => did !== currentAccount?.did, ) }, [userActionSnapshot, currentAccount]) const {data, isLoading, error} = useProfilesQuery({ handles: dids.slice(0, 16), }) const profiles = data ? data.profiles.filter(profile => { return !profile.viewer?.following }) : [] return { isLoading, error, profiles: profiles.slice(0, 6), } } 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 {gtMobile} = useBreakpoints() const moderationOpts = useModerationOpts() const maxLength = gtMobile ? 4 : 6 const { isLoading: isSuggestionsLoading, data, error, } = useSuggestedFollowsByActorQuery({ did, }) const { data: moreSuggestions, fetchNextPage, hasNextPage, isFetchingNextPage, } = useSuggestedFollowsQuery({limit: 25}) const [dismissedDids, setDismissedDids] = React.useState>( new Set(), ) const [dismissingDids, setDismissingDids] = React.useState>( new Set(), ) const onDismiss = React.useCallback((dismissedDid: string) => { // Start the fade animation setDismissingDids(prev => new Set(prev).add(dismissedDid)) // After animation completes, actually remove from list setTimeout(() => { setDismissedDids(prev => new Set(prev).add(dismissedDid)) setDismissingDids(prev => { const next = new Set(prev) next.delete(dismissedDid) return next }) }, DISMISS_ANIMATION_DURATION) }, []) // Combine profiles from the actor-specific query with fallback suggestions const allProfiles = React.useMemo(() => { const actorProfiles = data?.suggestions ?? [] const fallbackProfiles = moreSuggestions?.pages.flatMap(page => page.actors) ?? [] // Dedupe by did, preferring actor-specific profiles const seen = new Set() const combined: bsky.profile.AnyProfileView[] = [] for (const profile of actorProfiles) { if (!seen.has(profile.did)) { seen.add(profile.did) combined.push(profile) } } for (const profile of fallbackProfiles) { if (!seen.has(profile.did) && profile.did !== did) { seen.add(profile.did) combined.push(profile) } } return combined }, [data?.suggestions, moreSuggestions?.pages, did]) const filteredProfiles = React.useMemo(() => { return allProfiles.filter(p => !dismissedDids.has(p.did)) }, [allProfiles, dismissedDids]) // Fetch more when running low React.useEffect(() => { if ( moderationOpts && filteredProfiles.length < maxLength && hasNextPage && !isFetchingNextPage ) { fetchNextPage() } }, [ filteredProfiles.length, maxLength, hasNextPage, isFetchingNextPage, fetchNextPage, moderationOpts, ]) return ( ) } export function SuggestedFollowsHome() { const {gtMobile} = useBreakpoints() const moderationOpts = useModerationOpts() const maxLength = gtMobile ? 4 : 6 const { isLoading: isSuggestionsLoading, profiles: experimentalProfiles, error: experimentalError, } = useExperimentalSuggestedUsersQuery() const { data: moreSuggestions, fetchNextPage, hasNextPage, isFetchingNextPage, error: suggestionsError, } = useSuggestedFollowsQuery({limit: 25}) const [dismissedDids, setDismissedDids] = React.useState>( new Set(), ) const [dismissingDids, setDismissingDids] = React.useState>( new Set(), ) const onDismiss = React.useCallback((did: string) => { // Start the fade animation setDismissingDids(prev => new Set(prev).add(did)) // After animation completes, actually remove from list setTimeout(() => { setDismissedDids(prev => new Set(prev).add(did)) setDismissingDids(prev => { const next = new Set(prev) next.delete(did) return next }) }, DISMISS_ANIMATION_DURATION) }, []) // Combine profiles from experimental query with paginated suggestions const allProfiles = React.useMemo(() => { const fallbackProfiles = moreSuggestions?.pages.flatMap(page => page.actors) ?? [] // Dedupe by did, preferring experimental profiles const seen = new Set() const combined: bsky.profile.AnyProfileView[] = [] for (const profile of experimentalProfiles) { if (!seen.has(profile.did)) { seen.add(profile.did) combined.push(profile) } } for (const profile of fallbackProfiles) { if (!seen.has(profile.did)) { seen.add(profile.did) combined.push(profile) } } return combined }, [experimentalProfiles, moreSuggestions?.pages]) const filteredProfiles = React.useMemo(() => { return allProfiles.filter(p => !dismissedDids.has(p.did)) }, [allProfiles, dismissedDids]) // Fetch more when running low React.useEffect(() => { if ( moderationOpts && filteredProfiles.length < maxLength && hasNextPage && !isFetchingNextPage ) { fetchNextPage() } }, [ filteredProfiles.length, maxLength, hasNextPage, isFetchingNextPage, fetchNextPage, moderationOpts, ]) return ( ) } export function ProfileGrid({ isSuggestionsLoading, error, profiles, totalProfileCount, recId, viewContext = 'feed', onDismiss, dismissingDids, isVisible = true, }: { isSuggestionsLoading: boolean profiles: bsky.profile.AnyProfileView[] totalProfileCount?: number recId?: number error: Error | null dismissingDids?: Set viewContext: 'profile' | 'profileHeader' | 'feed' onDismiss?: (did: string) => void isVisible?: boolean }) { const t = useTheme() const ax = useAnalytics() const {_} = 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 ? 'InterstitialDiscover' : isProfileHeaderContext ? 'Profile' : 'InterstitialProfile' // 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.did)) { seenProfilesRef.current.add(profile.did) ax.metric('suggestedUser:seen', { logContext, recId, position: index, suggestedDid: profile.did, category: null, }) } }) }, [ax, isLoading, error, profiles, maxLength, logContext, recId]) // 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: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', recId, position: index, suggestedDid: profile.did, category: null, }) }} style={[a.flex_1]}> {({hovered, pressed}) => ( {onDismiss && ( )} { ax.metric('suggestedUser:follow', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', location: 'Card', recId, position: index, suggestedDid: profile.did, category: null, }) }} /> )} )) // Use totalProfileCount (before dismissals) for minLength check on initial render. const profileCountForMinCheck = totalProfileCount ?? profiles.length 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 )} {!isProfileHeaderContext && ( )} {gtMobile ? ( {content} ) : ( {content} {!isProfileHeaderContext && ( { followDialogControl.open() ax.metric('suggestedUser:seeMore', { logContext: 'Explore', }) }} /> )} )} ) } } function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { const {_} = useLingui() return ( ) } const numFeedsToDisplay = 3 export function SuggestedFeeds() { const t = useTheme() const ax = useAnalytics() const {_} = useLingui() const {data, isLoading, error} = useGetPopularFeedsQuery({ limit: numFeedsToDisplay, }) const navigation = useNavigation() const {gtMobile} = useBreakpoints() const feeds = React.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 ( ) }