Files
bsky-social-app/src/screens/Onboarding/StepFinished/index.tsx
T
Samuel Newman 74b0001335 migrate getAllListMembers to the appview client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:13:44 +03:00

356 lines
11 KiB
TypeScript

import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
type AppBskyActorProfile,
type AppBskyGraphDefs,
AppBskyGraphStarterpack,
type Un$Typed,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {uploadBlob} from '#/lib/api'
import {
BSKY_APP_ACCOUNT_DID,
DISCOVER_SAVED_FEED,
TIMELINE_SAVED_FEED,
VIDEO_SAVED_FEED,
} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {getAllListMembers} from '#/state/queries/list-members'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent, useAppviewClient} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from '#/state/shell/landing'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {
OnboardingControls,
OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout'
import {
type OnboardingState,
useOnboardingInternalState,
} from '#/screens/Onboarding/state'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Loader} from '#/components/Loader'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() {
const {state, dispatch} = useOnboardingInternalState()
const ax = useAnalytics()
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = useState(false)
const queryClient = useQueryClient()
const agent = useAgent()
const appviewClient = useAppviewClient()
const requestNotificationsPermission = useRequestNotificationsPermission()
const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const {startProgressGuide} = useProgressGuideControls()
const finishOnboarding = useCallback(async () => {
setSaving(true)
let starterPack: AppBskyGraphDefs.StarterPackView | undefined
let listItems: AppBskyGraphDefs.ListItemView[] | undefined
if (activeStarterPack?.uri) {
try {
const spRes = await agent.app.bsky.graph.getStarterPack({
starterPack: activeStarterPack.uri,
})
starterPack = spRes.data.starterPack
} catch (e) {
logger.error('Failed to fetch starter pack', {safeMessage: e})
// don't tell the user, just get them through onboarding.
}
try {
if (starterPack?.list) {
listItems = await getAllListMembers(
appviewClient,
starterPack.list.uri,
)
}
} catch (e) {
logger.error('Failed to fetch starter pack list items', {
safeMessage: e,
})
// don't tell the user, just get them through onboarding.
}
}
try {
const {interestsStepResults, profileStepResults} = state
const {selectedInterests} = interestsStepResults
await Promise.all([
bulkWriteFollows(
agent,
[BSKY_APP_ACCOUNT_DID, ...(listItems?.map(i => i.subject.did) ?? [])],
starterPack
? {uri: starterPack.uri, cid: starterPack.cid}
: undefined,
),
(async () => {
// Interests need to get saved first, then we can write the feeds to prefs
await agent.setInterestsPref({tags: selectedInterests})
// Default feeds that every user should have pinned when landing in the app
const feedsToSave: AppBskyActorDefs.SavedFeed[] = [
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
{
...VIDEO_SAVED_FEED,
id: TID.nextStr(),
},
]
// Any starter pack feeds will be pinned _after_ the defaults
if (starterPack && starterPack.feeds?.length) {
feedsToSave.push(
...starterPack.feeds.map(f => ({
type: 'feed',
value: f.uri,
pinned: true,
id: TID.nextStr(),
})),
)
}
await agent.overwriteSavedFeeds(feedsToSave)
})(),
(async () => {
const {imageUri, imageMime} = profileStepResults
const blobPromise =
imageUri && imageMime
? uploadBlob(agent, imageUri, imageMime)
: undefined
await agent.upsertProfile(async existing => {
let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {}
if (blobPromise) {
const res = await blobPromise
if (res.data.blob) {
next.avatar = res.data.blob
}
}
if (starterPack) {
next.joinedViaStarterPack = {
uri: starterPack.uri,
cid: starterPack.cid,
}
}
next.displayName = ''
if (!next.createdAt) {
next.createdAt = new Date().toISOString()
}
return next
})
ax.metric('onboarding:finished:avatarResult', {
avatarResult: profileStepResults.isCreatedAvatar
? 'created'
: profileStepResults.image
? 'uploaded'
: 'default',
})
})(),
requestNotificationsPermission('AfterOnboarding'),
])
} catch (e: any) {
logger.info(`onboarding: bulk save failed`)
logger.error(e)
// don't alert the user, just let them into their account
}
// Try to ensure that prefs and profile are up-to-date by the time we render Home.
await Promise.all([
queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
}),
queryClient.invalidateQueries({
queryKey: profileRQKey(agent.session?.did ?? ''),
}),
]).catch(e => {
logger.error(e)
// Keep going.
})
setSaving(false)
setActiveStarterPack(undefined)
setHasCheckedForStarterPack(true)
startProgressGuide('follow-10')
dispatch({type: 'finish'})
onboardDispatch({type: 'finish'})
ax.metric('onboarding:finished:nextPressed', {
usedStarterPack: Boolean(starterPack),
starterPackName:
starterPack &&
bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
)
? starterPack.record.name
: undefined,
starterPackCreator: starterPack?.creator.did,
starterPackUri: starterPack?.uri,
profilesFollowed: listItems?.length ?? 0,
feedsPinned: starterPack?.feeds?.length ?? 0,
})
if (starterPack && listItems?.length) {
ax.metric('starterPack:followAll', {
logContext: 'Onboarding',
starterPack: starterPack.uri,
count: listItems?.length,
})
}
}, [
ax,
queryClient,
agent,
appviewClient,
dispatch,
onboardDispatch,
activeStarterPack,
state,
requestNotificationsPermission,
setActiveStarterPack,
setHasCheckedForStarterPack,
startProgressGuide,
])
return (
<ValueProposition
finishOnboarding={finishOnboarding}
saving={saving}
state={state}
/>
)
}
function ValueProposition({
finishOnboarding,
saving,
state,
}: {
finishOnboarding: () => void
saving: boolean
state: OnboardingState
}) {
const [subStep, setSubStep] = useState<0 | 1 | 2>(0)
const {_} = useLingui()
const ax = useAnalytics()
const {gtMobile} = useBreakpoints()
const onPress = () => {
if (subStep === 2) {
finishOnboarding() // has its own metrics
} else if (subStep === 1) {
setSubStep(2)
ax.metric('onboarding:valueProp:stepTwo:nextPressed', {})
} else if (subStep === 0) {
setSubStep(1)
ax.metric('onboarding:valueProp:stepOne:nextPressed', {})
}
}
return (
<>
{!gtMobile && (
<OnboardingHeaderSlot.Portal>
<Button
disabled={saving}
variant="ghost"
color="secondary"
size="small"
label={_(msg`Skip introduction and start using your account`)}
onPress={() => {
ax.metric('onboarding:valueProp:skipPressed', {})
finishOnboarding()
}}
style={[a.bg_transparent]}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</OnboardingHeaderSlot.Portal>
)}
<ValuePropositionPager
step={subStep}
setStep={ss => setSubStep(ss)}
avatarUri={state.profileStepResults.imageUri}
/>
<OnboardingControls.Portal>
<View style={gtMobile && [a.gap_md, a.flex_row]}>
{gtMobile && (IS_WEB ? subStep !== 2 : true) && (
<Button
disabled={saving}
color="secondary"
size="large"
label={_(msg`Skip introduction and start using your account`)}
onPress={() => finishOnboarding()}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
)}
<Button
testID="onboardingFinish"
disabled={saving}
key={state.activeStep} // remove focus state on nav
color="primary"
size="large"
label={
subStep === 2
? _(msg`Complete onboarding and start using your account`)
: _(msg`Next`)
}
onPress={onPress}>
<ButtonText>
{saving ? (
<Trans>Finalizing</Trans>
) : subStep === 2 ? (
<Trans>Let's go!</Trans>
) : (
<Trans>Next</Trans>
)}
</ButtonText>
{subStep === 2 && (
<ButtonIcon icon={saving ? Loader : ArrowRight} />
)}
</Button>
</View>
</OnboardingControls.Portal>
</>
)
}