bsky-app/src/screens/Onboarding/StepFinished.tsx
Paul Frazee 0ed99b840d
New user progress guides (#4716)
* Add the animated checkmark svg

* Add progress guide list and task components

* Add ProgressGuide Toast component

* Implement progress-guide controller

* Add 7 follows to the progress guide

* Wire up action captures

* Wire up progress-guide persistence

* Trigger progress guide on account creation

* Clear the progress guide from storage on complete

* Add progress guide interstitial, put behind gate

* Fix: read progress guide state from prefs

* Some defensive type checks

* Create separate toast for completion

* List tweaks

* Only show on Discover

* Spacing and progress tweaks

* Completely hide when complete

* Capture the progress guide in local state, and only render toasts while guide is active

* Fix: ensure persisted hydrates into local state

* Gate

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-07-04 03:05:19 +01:00

304 lines
10 KiB
TypeScript

import React from 'react'
import {View} from 'react-native'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
import {TID} from '@atproto/common-web'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {
BSKY_APP_ACCOUNT_DID,
DISCOVER_SAVED_FEED,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {uploadBlob} from 'lib/api'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from 'state/shell/starter-pack'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {TOURS, useSetQueuedTour} from '#/tours'
export function StepFinished() {
const {_} = useLingui()
const t = useTheme()
const {track} = useAnalytics()
const {state, dispatch} = React.useContext(Context)
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false)
const queryClient = useQueryClient()
const agent = useAgent()
const requestNotificationsPermission = useRequestNotificationsPermission()
const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const setQueuedTour = useSetQueuedTour()
const {startProgressGuide} = useProgressGuideControls()
const finishOnboarding = React.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
if (starterPack.list) {
const listRes = await agent.app.bsky.graph.getList({
list: starterPack.list.uri,
limit: 50,
})
listItems = listRes.data.items
}
} catch (e) {
logger.error('Failed to fetch starter pack', {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) ?? []),
]),
(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: SavedFeed[] = [
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_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
if (imageUri && imageMime) {
const blobPromise = uploadBlob(agent, imageUri, imageMime)
await agent.upsertProfile(async existing => {
existing = existing ?? {}
const res = await blobPromise
if (res.data.blob) {
existing.avatar = res.data.blob
}
if (starterPack) {
existing.joinedViaStarterPack = {
uri: starterPack.uri,
cid: starterPack.cid,
}
}
existing.displayName = ''
// HACKFIX
// creating a bunch of identical profile objects is breaking the relay
// tossing this unspecced field onto it to reduce the size of the problem
// -prf
existing.createdAt = new Date().toISOString()
return existing
})
}
logEvent('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)
setQueuedTour(TOURS.HOME)
startProgressGuide('like-10-and-follow-7')
dispatch({type: 'finish'})
onboardDispatch({type: 'finish'})
track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {
usedStarterPack: Boolean(starterPack),
starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record)
? starterPack.record.name
: undefined,
starterPackCreator: starterPack?.creator.did,
starterPackUri: starterPack?.uri,
profilesFollowed: listItems?.length ?? 0,
feedsPinned: starterPack?.feeds?.length ?? 0,
})
if (starterPack && listItems?.length) {
logEvent('starterPack:followAll', {
logContext: 'Onboarding',
starterPack: starterPack.uri,
count: listItems?.length,
})
}
}, [
queryClient,
agent,
dispatch,
onboardDispatch,
track,
activeStarterPack,
state,
requestNotificationsPermission,
setActiveStarterPack,
setHasCheckedForStarterPack,
setQueuedTour,
startProgressGuide,
])
React.useEffect(() => {
track('OnboardingV2:StepFinished:Start')
}, [track])
return (
<View style={[a.align_start]}>
<IconCircle icon={Check} style={[a.mb_2xl]} />
<TitleText>
<Trans>You're ready to go!</Trans>
</TitleText>
<DescriptionText>
<Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
</DescriptionText>
<View style={[a.pt_5xl, a.gap_3xl]}>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Public</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Open</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Never lose access to your followers or data.</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle
icon={Trending}
size="lg"
style={{width: 48, height: 48}}
/>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Flexible</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Choose the algorithms that power your custom feeds.</Trans>
</Text>
</View>
</View>
</View>
<OnboardingControls.Portal>
<Button
disabled={saving}
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Complete onboarding and start using your account`)}
onPress={finishOnboarding}>
<ButtonText>
{saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
</ButtonText>
{saving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</OnboardingControls.Portal>
</View>
)
}