Files
bsky-social-app/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
T
Samuel Newman d6af1c5d36 point the remaining view moderation call sites at the sdk
The rest of the consumer surface - components, screens and the legacy `view`
tree - takes its moderation types from `@bsky.app/sdk/moderation` and its
`moderate*` calls from the `lib/moderation/subjects` seam.

`DebugMod` constructs a `ModerationOpts` by hand for its scenario matrix, so
its literal `userDid` is branded at the construction site.
2026-08-13 22:13:47 +03:00

458 lines
14 KiB
TypeScript

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<string | null>(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<string[]>([])
/*
* 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<Set<string>>(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 (
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
</Trans>
</OnboardingTitleText>
<View
style={[
a.overflow_hidden,
a.mt_sm,
IS_WEB
? [a.max_w_full, web({minHeight: '100vh'})]
: {marginHorizontal: tokens.space.xl * -1},
a.flex_1,
a.justify_start,
]}>
<TabBar
selectedInterest={selectedInterest}
onSelectInterest={setSelectedInterest}
defaultTabLabel={_(
msg({
message: 'All',
comment: 'the default tab in the interests tab bar',
}),
)}
selectedInterests={state.interestsStepResults.selectedInterests}
/>
{isLoading || !moderationOpts ? (
<View
style={[
a.flex_1,
a.mt_md,
a.align_center,
a.justify_center,
{minHeight: 400},
]}>
<Loader size="xl" />
</View>
) : isError ? (
<View style={[a.flex_1, a.px_xl, a.pt_2xl]}>
<Admonition type="error">
<Trans>
An error occurred while fetching suggested accounts.
</Trans>
</Admonition>
</View>
) : isEmpty ? (
<View style={[a.flex_1, a.px_xl, a.pt_2xl]}>
<Admonition type="apology">
<Trans>
Sorry, we're unable to load account suggestions at this time.
</Trans>
</Admonition>
</View>
) : (
<View
style={[
a.flex_1,
a.mt_md,
a.border_y,
t.atoms.border_contrast_low,
IS_WEB && [a.border_x, a.rounded_sm, a.overflow_hidden],
]}>
{suggestedUsers?.actors.map((user, index) => (
<SuggestedProfileCard
key={user.did}
profile={user}
moderationOpts={moderationOpts}
position={index}
category={selectedInterest}
onSeen={onProfileSeen}
recSource={!useFullExperience ? 'Search' : undefined}
recId={suggestedUsers.recId}
/>
))}
</View>
)}
</View>
<OnboardingControls.Portal>
{isError ? (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button
disabled={isRefetching}
color="secondary"
size="large"
label={_(msg`Retry`)}
onPress={() => void refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
</Button>
<Button
color="secondary"
size="large"
label={_(msg`Skip to next step`)}
onPress={() => dispatch({type: 'next'})}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</View>
) : (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button
disabled={!canFollowAll}
color="secondary"
size="large"
label={_(msg`Follow all accounts`)}
onPress={() => followAll()}>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
<ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} />
</Button>
<Button
disabled={isFollowingAll}
color="primary"
size="large"
label={_(msg`Continue to next step`)}
onPress={() => dispatch({type: 'next'})}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
</Button>
</View>
)}
</OnboardingControls.Portal>
</View>
)
}
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 (
<InterestTabs
interests={hideDefaultTab ? interests : ['all', ...interests]}
selectedInterest={
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
}
onSelectTab={tab => {
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<View>(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 (
<View
ref={cardRef}
style={[
a.w_full,
a.py_lg,
a.px_xl,
position !== 0 && a.border_t,
t.atoms.border_contrast_low,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="OnboardingSuggestedAccounts"
onFollow={() => {
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'Card',
recSource,
recId,
position,
suggestedDid: profile.did,
category,
})
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={3} />
</ProfileCard.Outer>
</View>
)
}