import React from 'react' import {ScrollView, View} from 'react-native' 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 {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' 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} 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 * 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 {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' import {ProgressGuideList} from './ProgressGuide/List' 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 { isLoading: isSuggestionsLoading, data, error, } = useSuggestedFollowsByActorQuery({ did, }) return ( ) } export function SuggestedFollowsHome() { const { isLoading: isSuggestionsLoading, profiles, error, } = useExperimentalSuggestedUsersQuery() return ( ) } export function ProfileGrid({ isSuggestionsLoading, error, profiles, recId, viewContext = 'feed', }: { isSuggestionsLoading: boolean profiles: bsky.profile.AnyProfileView[] recId?: number error: Error | null viewContext: 'profile' | 'profileHeader' | 'feed' }) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() const isLoading = isSuggestionsLoading || !moderationOpts const isProfileHeaderContext = viewContext === 'profileHeader' const isFeedContext = viewContext === 'feed' const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 const minLength = gtMobile ? 3 : 4 const content = isLoading ? Array(maxLength) .fill(0) .map((_, i) => ( )) : error || !profiles.length ? null : profiles.slice(0, maxLength).map((profile, index) => ( { logEvent('suggestedUser:press', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', recId, position: index, }) }} style={[ a.flex_1, gtMobile && web([ a.flex_0, a.flex_grow, {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, ]), ]}> {({hovered, pressed}) => ( { logEvent('suggestedUser:follow', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', location: 'Card', recId, position: index, }) }} /> )} )) if (error || (!isLoading && profiles.length < minLength)) { logger.debug(`Not enough profiles to show suggested follows`) return null } return ( {isFeedContext ? ( Suggested for you ) : ( Similar accounts )} {!isProfileHeaderContext && ( See more )} {gtMobile ? ( {content} ) : ( {content} {!isProfileHeaderContext && } )} ) } function SeeMoreSuggestedProfilesCard() { const t = useTheme() const {_} = useLingui() const navigation = useNavigation() return ( ) } export function SuggestedFeeds() { const numFeedsToDisplay = 3 const t = useTheme() 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 => ( { logEvent('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() return ( ) }