import {useCallback, useContext, useState} from 'react' import {View} from 'react-native' import Animated, { Easing, LayoutAnimationConfig, SlideInRight, SlideOutLeft, } from 'react-native-reanimated' import {Image} from 'expo-image' import { type AppBskyActorDefs, type AppBskyActorProfile, type AppBskyGraphDefs, AppBskyGraphStarterpack, type Un$Typed, } from '@atproto/api' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' 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 {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isNative} from '#/platform/detection' 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} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {useProgressGuideControls} from '#/state/shell/progress-guide' import { useActiveStarterPack, useSetActiveStarterPack, } from '#/state/shell/starter-pack' import { DescriptionText, OnboardingControls, OnboardingHeaderSlot, TitleText, } from '#/screens/Onboarding/Layout' import {Context, type OnboardingState} from '#/screens/Onboarding/state' import {bulkWriteFollows} from '#/screens/Onboarding/util' import { atoms as a, native, platform, tokens, useBreakpoints, useTheme, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {IconCircle} from '#/components/IconCircle' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 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/Trending' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' export function StepFinished() { const {_} = useLingui() const {state, dispatch} = useContext(Context) const onboardDispatch = useOnboardingDispatch() const [saving, setSaving] = useState(false) const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() const activeStarterPack = useActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const {startProgressGuide} = useProgressGuideControls() const gate = useGate() 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(agent, 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) ?? []), ]), (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(), }, ] if (gate('onboarding_add_video_feed')) { feedsToSave.push({ ...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 = 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 = '' // 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 next.createdAt = new Date().toISOString() return next }) 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) startProgressGuide( gate('old_postonboarding') ? 'like-10-and-follow-7' : 'follow-10', ) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) logEvent('onboarding:finished:nextPressed', { usedStarterPack: Boolean(starterPack), starterPackName: starterPack && bsky.dangerousIsType( 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) { logEvent('starterPack:followAll', { logContext: 'Onboarding', starterPack: starterPack.uri, count: listItems?.length, }) } }, [ queryClient, agent, dispatch, onboardDispatch, activeStarterPack, state, requestNotificationsPermission, setActiveStarterPack, setHasCheckedForStarterPack, startProgressGuide, gate, ]) return state.experiments?.onboarding_value_prop ? ( ) : ( ) } const PROP_1 = { light: platform({ native: require('../../../assets/images/onboarding/value_prop_1_light.webp'), web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'), }), dim: platform({ native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'), web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'), }), dark: platform({ native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'), web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'), }), } as const const PROP_2 = { light: require('../../../assets/images/onboarding/value_prop_2_light.webp'), dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'), dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'), } as const const PROP_3 = { light: require('../../../assets/images/onboarding/value_prop_3_light.webp'), dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'), dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'), } as const function ValueProposition({ finishOnboarding, saving, state, }: { finishOnboarding: () => void saving: boolean state: OnboardingState }) { const [subStep, setSubStep] = useState<0 | 1 | 2>(0) const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep] const onPress = () => { if (subStep === 2) { finishOnboarding() // has its own metrics } else if (subStep === 1) { setSubStep(2) logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) } else if (subStep === 0) { setSubStep(1) logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) } } const {title, description, alt} = [ { title: _(msg`Free your feed`), description: _( msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`, ), alt: _( msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`, ), }, { title: _(msg`Find your people`), description: _( msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`, ), alt: _( msg`Your profile picture surrounded by concentric circles of other users' profile pictures`, ), }, { title: _(msg`Forget the noise`), description: _( msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`, ), alt: _( msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`, ), }, ][subStep] return ( <> {!gtMobile && ( )} {alt} {subStep === 1 && ( {_(msg`Your )} {title} {description} {gtMobile && ( )} ) } function Dot({active}: {active: boolean}) { const t = useTheme() const {_} = useLingui() return ( ) } function LegacyFinalStep({ finishOnboarding, saving, state, }: { finishOnboarding: () => void saving: boolean state: OnboardingState }) { const t = useTheme() const {_} = useLingui() return ( You're ready to go! We hope you have a wonderful time. Remember, Bluesky is: Public Your posts, likes, and blocks are public. Mutes are private. Open Never lose access to your followers or data. Flexible Choose the algorithms that power your custom feeds. ) }