import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {View} from 'react-native' import {type ModerationOpts} from '@bsky.app/sdk/moderation' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQueryClient} from '@tanstack/react-query' import * as bcp47Match from 'bcp-47-match' import {wait} from '#/lib/async/wait' import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {logger} from '#/logger' import {updateProfileShadow} from '#/state/cache/profile-shadow' import {useLanguagePrefs} from '#/state/preferences' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useAppviewClient, usePdsClient, useSession} from '#/state/session' import { OnboardingControls, OnboardingPosition, OnboardingTitleText, } from '#/screens/Onboarding/Layout' import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {useSuggestedOnboardingUsers} from '#/screens/Search/util/useSuggestedOnboardingUsers' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {boostInterests, InterestTabs} from '#/components/InterestTabs' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import * as toast from '#/components/Toast' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {bulkWriteFollows} from '../util' export function StepSuggestedAccounts() { const {_} = useLingui() const ax = useAnalytics() const t = useTheme() const {gtMobile} = useBreakpoints() const moderationOpts = useModerationOpts() const appviewClient = useAppviewClient() const pdsClient = usePdsClient() const {currentAccount} = useSession() const queryClient = useQueryClient() const {state, dispatch} = useOnboardingInternalState() const [selectedInterest, setSelectedInterest] = useState(null) // keeping track of who was followed via the follow all button // so we can enable/disable the button without having to dig through the shadow cache const [followedUsers, setFollowedUsers] = useState([]) /* * Special language handling copied wholesale from the Explore screen */ const {contentLanguages} = useLanguagePrefs() const useFullExperience = useMemo(() => { if (contentLanguages.length === 0) return true return bcp47Match.basicFilter('en', contentLanguages).length > 0 }, [contentLanguages]) const interestsDisplayNames = useInterestsDisplayNames() const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(state.interestsStepResults.selectedInterests)) const { data: suggestedUsers, isLoading, error, isRefetching, refetch, } = useSuggestedOnboardingUsers({ category: selectedInterest || (useFullExperience ? null : interests[0]), search: !useFullExperience, overrideInterests: state.interestsStepResults.selectedInterests, }) const isError = !!error const isEmpty = !isLoading && suggestedUsers && suggestedUsers.actors.length === 0 const followableDids = suggestedUsers?.actors .filter( user => user.did !== currentAccount?.did && !isBlockedOrBlocking(user) && !isMuted(user) && !user.viewer?.following && !followedUsers.includes(user.did), ) .map(user => user.did) ?? [] const {mutate: followAll, isPending: isFollowingAll} = useMutation({ onMutate: () => { ax.metric('onboarding:suggestedAccounts:followAllPressed', { tab: selectedInterest ?? 'all', numAccounts: followableDids.length, }) for (let i = 0; i < followableDids.length; i++) { const did = followableDids[i] ax.metric('suggestedUser:follow', { logContext: 'Onboarding', location: 'FollowAll', recSource: !useFullExperience ? 'Search' : undefined, recId: suggestedUsers?.recId, position: i, suggestedDid: did, category: selectedInterest, }) } }, mutationFn: async () => { for (const did of followableDids) { updateProfileShadow(queryClient, did, { followingUri: 'pending', }) } const uris = await wait( 1e3, bulkWriteFollows(pdsClient, appviewClient, followableDids), ) for (const did of followableDids) { const uri = uris.get(did) updateProfileShadow(queryClient, did, { followingUri: uri, }) } return followableDids }, onSuccess: newlyFollowed => { toast.show(_(msg`Followed all accounts!`), {type: 'success'}) setFollowedUsers(followed => [...followed, ...newlyFollowed]) }, onError: e => { logger.error( 'Failed to follow all suggested accounts during onboarding', { safeMessage: e, }, ) toast.show( _(msg`Failed to follow all suggested accounts, please try again`), {type: 'error'}, ) }, }) const canFollowAll = followableDids.length > 0 && !isFollowingAll // Track seen profiles - shared ref across all cards const seenProfilesRef = useRef>(new Set()) const onProfileSeen = useCallback( (did: string, position: number) => { if (!seenProfilesRef.current.has(did)) { seenProfilesRef.current.add(did) ax.metric('suggestedUser:seen', { logContext: 'Onboarding', recSource: !useFullExperience ? 'Search' : undefined, recId: suggestedUsers?.recId, position, suggestedDid: did, category: selectedInterest, }) } }, [ax, selectedInterest, suggestedUsers?.recId, useFullExperience], ) useEffect(() => { if (error) { logger.error('Failed to fetch suggested accounts during onboarding', { safeMessage: error, }) } }, [error]) return ( Suggested for you {isLoading || !moderationOpts ? ( ) : isError ? ( An error occurred while fetching suggested accounts. ) : isEmpty ? ( Sorry, we're unable to load account suggestions at this time. ) : ( {suggestedUsers?.actors.map((user, index) => ( ))} )} {isError ? ( ) : ( )} ) } function TabBar({ selectedInterest, onSelectInterest, selectedInterests, hideDefaultTab, defaultTabLabel, }: { selectedInterest: string | null onSelectInterest: (interest: string | null) => void selectedInterests: string[] hideDefaultTab?: boolean defaultTabLabel?: string }) { const {_} = useLingui() const ax = useAnalytics() const interestsDisplayNames = useInterestsDisplayNames() const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(selectedInterests)) return ( { ax.metric('onboarding:suggestedAccounts:tabPressed', {tab: tab}) onSelectInterest(tab === 'all' ? null : tab) }} interestsDisplayNames={ hideDefaultTab ? interestsDisplayNames : { all: defaultTabLabel || _(msg`For You`), ...interestsDisplayNames, } } gutterWidth={IS_WEB ? 0 : tokens.space.xl} /> ) } function SuggestedProfileCard({ profile, moderationOpts, position, category, onSeen, recSource, recId, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts position: number category: string | null onSeen: (did: string, position: number) => void recSource?: 'Search' recId?: string }) { const t = useTheme() const ax = useAnalytics() const cardRef = useRef(null) const hasTrackedRef = useRef(false) useEffect(() => { const node = cardRef.current if (!node || hasTrackedRef.current) return if (IS_WEB && typeof IntersectionObserver !== 'undefined') { const observer = new IntersectionObserver( entries => { if (entries[0]?.isIntersecting && !hasTrackedRef.current) { hasTrackedRef.current = true onSeen(profile.did, position) observer.disconnect() } }, {threshold: 0.5}, ) // @ts-ignore - web only observer.observe(node) return () => observer.disconnect() } else { // Native: use a short delay to account for initial layout const timeout = setTimeout(() => { if (!hasTrackedRef.current) { hasTrackedRef.current = true onSeen(profile.did, position) } }, 500) return () => clearTimeout(timeout) } }, [onSeen, profile.did, position]) return ( { ax.metric('suggestedUser:follow', { logContext: 'Onboarding', location: 'Card', recSource, recId, position, suggestedDid: profile.did, category, }) }} /> ) }