diff options
Diffstat (limited to 'src/screens/Onboarding')
-rw-r--r-- | src/screens/Onboarding/Layout.tsx | 126 | ||||
-rw-r--r-- | src/screens/Onboarding/StepFinished.tsx | 293 | ||||
-rw-r--r-- | src/screens/Onboarding/StepInterests/index.tsx | 12 | ||||
-rw-r--r-- | src/screens/Onboarding/StepProfile/index.tsx | 10 | ||||
-rw-r--r-- | src/screens/Onboarding/StepSuggestedAccounts/index.tsx | 356 | ||||
-rw-r--r-- | src/screens/Onboarding/index.tsx | 51 | ||||
-rw-r--r-- | src/screens/Onboarding/state.ts | 58 | ||||
-rw-r--r-- | src/screens/Onboarding/util.ts | 14 |
8 files changed, 823 insertions, 97 deletions
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 16c37358f..6394d9c96 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useState} from 'react' import {ScrollView, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' @@ -11,20 +11,23 @@ import { atoms as a, flatten, native, - TextStyleProp, + type TextStyleProp, + tokens, useBreakpoints, useTheme, web, } from '#/alf' import {leading} from '#/alf/typography' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' +import {HEADER_SLOT_SIZE} from '#/components/Layout' import {createPortalGroup} from '#/components/Portal' import {P, Text} from '#/components/Typography' -const COL_WIDTH = 420 +const ONBOARDING_COL_WIDTH = 420 export const OnboardingControls = createPortalGroup() +export const OnboardingHeaderSlot = createPortalGroup() export function Layout({children}: React.PropsWithChildren<{}>) { const {_} = useLingui() @@ -46,6 +49,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) { const paddingTop = gtMobile ? a.py_5xl : a.py_lg const dialogLabel = _(msg`Set up your account`) + const [footerHeight, setFooterHeight] = useState(0) + return ( <View aria-modal @@ -62,45 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) { t.atoms.bg, ]}> {__DEV__ && ( - <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> - <Button - variant="ghost" - color="negative" - size="small" - onPress={() => onboardDispatch({type: 'skip'})} - // DEV ONLY - label="Clear onboarding state"> - <ButtonText>Clear</ButtonText> - </Button> - </View> + <Button + variant="ghost" + color="negative" + size="tiny" + onPress={() => onboardDispatch({type: 'skip'})} + // DEV ONLY + label="Clear onboarding state" + style={[ + a.absolute, + a.z_10, + { + left: '50%', + top: insets.top + 2, + transform: [{translateX: '-50%'}], + }, + ]}> + <ButtonText>[DEV] Clear</ButtonText> + </Button> )} - {!gtMobile && state.hasPrev && ( + {!gtMobile && ( <View + pointerEvents="box-none" style={[ web(a.fixed), native(a.absolute), + a.left_0, + a.right_0, a.flex_row, a.w_full, a.justify_center, a.z_20, a.px_xl, - { - top: paddingTop.paddingTop + insets.top - 1, - }, + {top: paddingTop.paddingTop + insets.top - 1}, ]}> - <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> - <Button - key={state.activeStep} // remove focus state on nav - variant="ghost" - color="secondary" - size="small" - shape="round" - label={_(msg`Go back to previous step`)} - style={[a.absolute]} - onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> - </Button> + <View + pointerEvents="box-none" + style={[ + a.w_full, + a.align_start, + a.flex_row, + a.justify_between, + {maxWidth: ONBOARDING_COL_WIDTH}, + ]}> + {state.hasPrev ? ( + <Button + key={state.activeStep} // remove focus state on nav + color="secondary" + variant="ghost" + shape="square" + size="small" + label={_(msg`Go back to previous step`)} + onPress={() => dispatch({type: 'prev'})} + style={[a.bg_transparent]}> + <ButtonIcon icon={ArrowLeft} size="lg" /> + </Button> + ) : ( + <View /> + )} + + <OnboardingHeaderSlot.Outlet /> </View> </View> )} @@ -109,22 +136,24 @@ export function Layout({children}: React.PropsWithChildren<{}>) { ref={scrollview} style={[a.h_full, a.w_full, {paddingTop: insets.top}]} contentContainerStyle={{borderWidth: 0}} - // @ts-ignore web only --prf + scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} + // @ts-expect-error web only --prf dataSet={{'stable-gutters': 1}}> <View style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> - <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> + <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> <View style={[a.w_full, a.align_center, paddingTop]}> <View style={[ a.flex_row, a.gap_sm, a.w_full, - {paddingTop: 17, maxWidth: '60%'}, + a.align_center, + {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, ]}> {Array(state.totalSteps) .fill(0) - .map((_, i) => ( + .map((__, i) => ( <View key={i} style={[ @@ -144,19 +173,16 @@ export function Layout({children}: React.PropsWithChildren<{}>) { </View> </View> - <View - style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> - {children} - </View> + <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View> - <View style={{height: 400}} /> + <View style={{height: 100 + footerHeight}} /> </View> </View> </ScrollView> <View + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} style={[ - // @ts-ignore web only -prf isWeb ? a.fixed : a.absolute, {bottom: 0, left: 0, right: 0}, t.atoms.bg, @@ -167,30 +193,30 @@ export function Layout({children}: React.PropsWithChildren<{}>) { isWeb ? a.py_2xl : { - paddingTop: a.pt_lg.paddingTop, - paddingBottom: insets.bottom + 10, + paddingTop: tokens.space.md, + paddingBottom: insets.bottom + tokens.space.md, }, ]}> <View style={[ a.w_full, - {maxWidth: COL_WIDTH}, - gtMobile && [a.flex_row, a.justify_between], + {maxWidth: ONBOARDING_COL_WIDTH}, + gtMobile && [a.flex_row, a.justify_between, a.align_center], ]}> {gtMobile && (state.hasPrev ? ( <Button key={state.activeStep} // remove focus state on nav - variant="solid" color="secondary" - size="large" - shape="round" + variant="ghost" + shape="square" + size="small" label={_(msg`Go back to previous step`)} onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> + <ButtonIcon icon={ArrowLeft} size="lg" /> </Button> ) : ( - <View style={{height: 54}} /> + <View style={{height: 33}} /> ))} <OnboardingControls.Outlet /> </View> diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 54d282a5e..c4b723ce1 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,5 +1,12 @@ -import React from 'react' +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, @@ -22,6 +29,7 @@ import { 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' @@ -36,13 +44,22 @@ import { import { DescriptionText, OnboardingControls, + OnboardingHeaderSlot, TitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {Context, type OnboardingState} from '#/screens/Onboarding/state' import {bulkWriteFollows} from '#/screens/Onboarding/util' -import {atoms as a, useTheme} from '#/alf' +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' @@ -53,10 +70,9 @@ import * as bsky from '#/types/bsky' export function StepFinished() { const {_} = useLingui() - const t = useTheme() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useContext(Context) const onboardDispatch = useOnboardingDispatch() - const [saving, setSaving] = React.useState(false) + const [saving, setSaving] = useState(false) const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -66,7 +82,7 @@ export function StepFinished() { const {startProgressGuide} = useProgressGuideControls() const gate = useGate() - const finishOnboarding = React.useCallback(async () => { + const finishOnboarding = useCallback(async () => { setSaving(true) let starterPack: AppBskyGraphDefs.StarterPackView | undefined @@ -245,6 +261,267 @@ export function StepFinished() { gate, ]) + return state.experiments?.onboarding_value_prop ? ( + <ValueProposition + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) : ( + <LegacyFinalStep + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) +} + +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 && ( + <OnboardingHeaderSlot.Portal> + <Button + disabled={saving} + variant="ghost" + color="secondary" + size="small" + label={_(msg`Skip introduction and start using your account`)} + onPress={() => { + logger.metric('onboarding:valueProp:skipPressed', {}) + finishOnboarding() + }} + style={[a.bg_transparent]}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </OnboardingHeaderSlot.Portal> + )} + + <LayoutAnimationConfig skipEntering skipExiting> + <Animated.View + key={subStep} + entering={native( + SlideInRight.easing(Easing.out(Easing.exp)).duration(500), + )} + exiting={native( + SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500), + )}> + <View + style={[ + a.relative, + a.align_center, + a.justify_center, + isNative && {marginHorizontal: tokens.space.xl * -1}, + a.pointer_events_none, + ]}> + <Image + source={image} + style={[a.w_full, {aspectRatio: 1}]} + alt={alt} + accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background + /> + {subStep === 1 && ( + <Image + source={state.profileStepResults.imageUri} + style={[ + a.z_10, + a.absolute, + a.rounded_full, + { + width: `${(80 / 393) * 100}%`, + height: `${(80 / 393) * 100}%`, + }, + ]} + accessibilityIgnoresInvertColors + alt={_(msg`Your profile picture`)} + /> + )} + </View> + + <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}> + <View style={[a.flex_row, a.gap_sm]}> + <Dot active={subStep === 0} /> + <Dot active={subStep === 1} /> + <Dot active={subStep === 2} /> + </View> + + <View style={[a.gap_sm]}> + <Text style={[a.font_heavy, a.text_3xl, a.text_center]}> + {title} + </Text> + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_md, + a.leading_snug, + a.text_center, + ]}> + {description} + </Text> + </View> + </View> + </Animated.View> + </LayoutAnimationConfig> + + <OnboardingControls.Portal> + <View style={gtMobile && [a.gap_md, a.flex_row]}> + {gtMobile && ( + <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 + 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> + </> + ) +} + +function Dot({active}: {active: boolean}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <View + style={[ + a.rounded_full, + {width: 8, height: 8}, + active + ? {backgroundColor: t.palette.primary_500} + : t.atoms.bg_contrast_50, + ]} + /> + ) +} + +function LegacyFinalStep({ + finishOnboarding, + saving, + state, +}: { + finishOnboarding: () => void + saving: boolean + state: OnboardingState +}) { + const t = useTheme() + const {_} = useLingui() + return ( <View style={[a.align_start]}> <IconCircle icon={Check} style={[a.mb_2xl]} /> @@ -303,9 +580,9 @@ export function StepFinished() { <OnboardingControls.Portal> <Button + testID="onboardingFinish" disabled={saving} key={state.activeStep} // remove focus state on nav - variant="solid" color="primary" size="large" label={_(msg`Complete onboarding and start using your account`)} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2a121cac6..d214937d4 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -160,7 +160,16 @@ export function StepInterests() { <View style={[a.w_full, a.pt_2xl]}> {isLoading ? ( - <Loader size="xl" /> + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> ) : isError || !data ? ( <View style={[ @@ -235,6 +244,7 @@ export function StepInterests() { ) : ( <Button disabled={saving || !data} + testID="onboardingContinue" variant="solid" color="primary" size="large" diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 30da5cbb5..6066e4297 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -4,7 +4,7 @@ import {Image as ExpoImage} from 'expo-image' import { type ImagePickerOptions, launchImageLibraryAsync, - MediaTypeOptions, + UIImagePickerPreferredAssetRepresentationMode, } from 'expo-image-picker' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -97,10 +97,12 @@ export function StepProfile() { const response = await sheetWrapper( launchImageLibraryAsync({ exif: false, - mediaTypes: MediaTypeOptions.Images, + mediaTypes: ['images'], quality: 1, ...opts, legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Automatic, }), ) @@ -266,8 +268,9 @@ export function StepProfile() { </View> <OnboardingControls.Portal> - <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> + <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> <Button + testID="onboardingContinue" variant="solid" color="primary" size="large" @@ -279,6 +282,7 @@ export function StepProfile() { <ButtonIcon icon={ChevronRight} position="right" /> </Button> <Button + testID="onboardingAvatarCreator" variant="ghost" color="primary" size="large" diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 000000000..5a9d3464c --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -0,0 +1,356 @@ +import {useCallback, useContext, useMemo, useState} from 'react' +import {View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' +import * as bcp47Match from 'bcp-47-match' + +import {wait} from '#/lib/async/wait' +import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useLanguagePrefs} from '#/state/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent, useSession} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {OnboardingControls} from '#/screens/Onboarding/Layout' +import { + Context, + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' +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 {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {bulkWriteFollows} from '../util' + +export function StepSuggestedAccounts() { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const agent = useAgent() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + const {state, dispatch} = useContext(Context) + const onboardDispatch = useOnboardingDispatch() + + 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, + } = useSuggestedUsers({ + category: selectedInterest || (useFullExperience ? null : interests[0]), + search: !useFullExperience, + overrideInterests: state.interestsStepResults.selectedInterests, + }) + + const isError = !!error + + const skipOnboarding = useCallback(() => { + onboardDispatch({type: 'finish'}) + dispatch({type: 'finish'}) + }, [onboardDispatch, dispatch]) + + 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: () => { + logger.metric('onboarding:suggestedAccounts:followAllPressed', { + tab: selectedInterest ?? 'all', + numAccounts: followableDids.length, + }) + }, + mutationFn: async () => { + for (const did of followableDids) { + updateProfileShadow(queryClient, did, { + followingUri: 'pending', + }) + } + const uris = await wait(1e3, bulkWriteFollows(agent, 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: () => { + toast.show( + _(msg`Failed to follow all suggested accounts, please try again`), + {type: 'error'}, + ) + }, + }) + + const canFollowAll = followableDids.length > 0 && !isFollowingAll + + return ( + <View style={[a.align_start]} testID="onboardingInterests"> + <Text style={[a.font_heavy, a.text_3xl]}> + <Trans comment="Accounts suggested to the user for them to follow"> + Suggested for you + </Trans> + </Text> + + <View + style={[ + a.overflow_hidden, + a.mt_lg, + isWeb ? a.max_w_full : {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_5xl]}> + <Admonition type="error"> + <Trans> + An error occurred while fetching suggested accounts. + </Trans> + </Admonition> + </View> + ) : ( + <View + style={[ + a.flex_1, + a.mt_md, + a.border_y, + t.atoms.border_contrast_low, + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], + ]}> + {suggestedUsers?.actors.map((user, index) => ( + <SuggestedProfileCard + key={user.did} + profile={user} + moderationOpts={moderationOpts} + position={index} + /> + ))} + </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={() => refetch()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> + </Button> + <Button + color="secondary" + size="large" + label={_(msg`Skip this flow`)} + onPress={skipOnboarding}> + <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 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 => { + logger.metric( + 'onboarding:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + interestsDisplayNames={ + hideDefaultTab + ? interestsDisplayNames + : { + all: defaultTabLabel || _(msg`For You`), + ...interestsDisplayNames, + } + } + gutterWidth={isWeb ? 0 : tokens.space.xl} + /> + ) +} + +function SuggestedProfileCard({ + profile, + moderationOpts, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + position: number +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + 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={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Onboarding', + location: 'Card', + recId: undefined, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={3} /> + </ProfileCard.Outer> + </View> + ) +} diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index a5c423ca1..f13402ece 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -1,21 +1,37 @@ -import React from 'react' +import {useMemo, useReducer} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' +import {useGate} from '#/lib/statsig/statsig' +import { + Layout, + OnboardingControls, + OnboardingHeaderSlot, +} from '#/screens/Onboarding/Layout' import {Context, initialState, reducer} from '#/screens/Onboarding/state' import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepProfile} from '#/screens/Onboarding/StepProfile' import {Portal} from '#/components/Portal' +import {ENV} from '#/env' +import {StepSuggestedAccounts} from './StepSuggestedAccounts' export function Onboarding() { const {_} = useLingui() - const [state, dispatch] = React.useReducer(reducer, { + const gate = useGate() + const showValueProp = ENV !== 'e2e' && gate('onboarding_value_prop') + const showSuggestedAccounts = + ENV !== 'e2e' && gate('onboarding_suggested_accounts') + const [state, dispatch] = useReducer(reducer, { ...initialState, + totalSteps: showSuggestedAccounts ? 4 : 3, + experiments: { + onboarding_suggested_accounts: showSuggestedAccounts, + onboarding_value_prop: showValueProp, + }, }) - const interestsDisplayNames = React.useMemo(() => { + const interestsDisplayNames = useMemo(() => { return { news: _(msg`News`), journalism: _(msg`Journalism`), @@ -45,17 +61,22 @@ export function Onboarding() { return ( <Portal> <OnboardingControls.Provider> - <Context.Provider - value={React.useMemo( - () => ({state, dispatch, interestsDisplayNames}), - [state, dispatch, interestsDisplayNames], - )}> - <Layout> - {state.activeStep === 'profile' && <StepProfile />} - {state.activeStep === 'interests' && <StepInterests />} - {state.activeStep === 'finished' && <StepFinished />} - </Layout> - </Context.Provider> + <OnboardingHeaderSlot.Provider> + <Context.Provider + value={useMemo( + () => ({state, dispatch, interestsDisplayNames}), + [state, dispatch, interestsDisplayNames], + )}> + <Layout> + {state.activeStep === 'profile' && <StepProfile />} + {state.activeStep === 'interests' && <StepInterests />} + {state.activeStep === 'suggested-accounts' && ( + <StepSuggestedAccounts /> + )} + {state.activeStep === 'finished' && <StepFinished />} + </Layout> + </Context.Provider> + </OnboardingHeaderSlot.Provider> </OnboardingControls.Provider> </Portal> ) diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index cbb466245..31f6eb039 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -11,7 +11,7 @@ import { export type OnboardingState = { hasPrev: boolean totalSteps: number - activeStep: 'profile' | 'interests' | 'finished' + activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished' activeStepIndex: number interestsStepResults: { @@ -34,6 +34,11 @@ export type OnboardingState = { backgroundColor: AvatarColor } } + + experiments?: { + onboarding_suggested_accounts?: boolean + onboarding_value_prop?: boolean + } } export type OnboardingAction = @@ -160,22 +165,49 @@ export function reducer( switch (a.type) { case 'next': { - if (s.activeStep === 'profile') { - next.activeStep = 'interests' - next.activeStepIndex = 2 - } else if (s.activeStep === 'interests') { - next.activeStep = 'finished' - next.activeStepIndex = 3 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'finished' + next.activeStepIndex = 4 + } + } else { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'finished' + next.activeStepIndex = 3 + } } break } case 'prev': { - if (s.activeStep === 'interests') { - next.activeStep = 'profile' - next.activeStepIndex = 1 - } else if (s.activeStep === 'finished') { - next.activeStep = 'interests' - next.activeStepIndex = 2 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'finished') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + } else { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'finished') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } } break } diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index d14c9562e..b08f0408e 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -1,9 +1,9 @@ import { - $Typed, - AppBskyGraphFollow, - AppBskyGraphGetFollows, - BskyAgent, - ComAtprotoRepoApplyWrites, + type $Typed, + type AppBskyGraphFollow, + type AppBskyGraphGetFollows, + type BskyAgent, + type ComAtprotoRepoApplyWrites, } from '@atproto/api' import {TID} from '@atproto/common-web' import chunk from 'lodash.chunk' @@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) { } await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) - const followUris = new Map() + const followUris = new Map<string, string>() for (const r of followWrites) { followUris.set( - r.value.subject, + r.value.subject as string, `at://${session.did}/app.bsky.graph.follow/${r.rkey}`, ) } |