Add suggested follows experiment to onboarding (#8847)

* add new gated screen to onboarding

* add tab bar, adjust layout

* replace chevron with arrow

* get suggested accounts working on native

* tweaks for web

* add metrics to follow all

* rm non-functional link from card

* ensure selected interests are passed through to interests query

* fix logcontext

* followed all accounts! toast

* rm save interests function

* Update src/screens/Onboarding/StepSuggestedAccounts/index.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use admonition

* rm comment

* Better interest tabs (#8879)

* make tabs draggable

* move tab component to own file

* rm focused state from tab, improve label

* add focus styles, remove focus when dragging

* rm log

* add arrows to tabs

* rename Tabs -> InterestTabs

* try and simplify approach

* rename ref

* Update InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add newline

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix flex problem

* Add value proposition screen experiment (#8898)

* add assets

* add value prop experiment

* add alt text

* add metrics

* add transitions

* add skip button

* tweak copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add borderless variant for web

* rm pointer events

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add slight delay, prevent layout shift

* Handle layout shift, add Let's Go! text

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-08-27 14:17:45 +03:00
committed by GitHub
parent 0617fca5ef
commit eac0290143
30 changed files with 1302 additions and 291 deletions
+76 -50
View File
@@ -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>
+284 -8
View File
@@ -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]} />
@@ -305,7 +582,6 @@ export function StepFinished() {
<Button
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`)}
+10 -1
View File
@@ -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={[
+1 -1
View File
@@ -266,7 +266,7 @@ 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
variant="solid"
color="primary"
@@ -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>
)
}
+34 -15
View File
@@ -1,21 +1,35 @@
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 {StepSuggestedAccounts} from './StepSuggestedAccounts'
export function Onboarding() {
const {_} = useLingui()
const [state, dispatch] = React.useReducer(reducer, {
const gate = useGate()
const showValueProp = gate('onboarding_value_prop')
const showSuggestedAccounts = 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 +59,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>
)
+45 -13
View File
@@ -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
}
+7 -7
View File
@@ -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}`,
)
}
+1 -1
View File
@@ -66,9 +66,9 @@ import {
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {StarterPack} from '#/components/icons/StarterPack'
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {boostInterests} from '#/components/InterestTabs'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
@@ -14,11 +14,9 @@ import {
} from '#/screens/Onboarding/state'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function useLoadEnoughProfiles({
@@ -59,10 +57,12 @@ export function SuggestedAccountsTabBar({
selectedInterest,
onSelectInterest,
hideDefaultTab,
defaultTabLabel,
}: {
selectedInterest: string | null
onSelectInterest: (interest: string | null) => void
hideDefaultTab?: boolean
defaultTabLabel?: string
}) {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
@@ -71,9 +71,10 @@ export function SuggestedAccountsTabBar({
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
return (
<BlockDrawerGesture>
<Tabs
<InterestTabs
interests={hideDefaultTab ? interests : ['all', ...interests]}
selectedInterest={
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
@@ -86,82 +87,19 @@ export function SuggestedAccountsTabBar({
)
onSelectInterest(tab === 'all' ? null : tab)
}}
hasSearchText={false}
interestsDisplayNames={
hideDefaultTab
? interestsDisplayNames
: {
all: _(msg`For You`),
all: defaultTabLabel || _(msg`For You`),
...interestsDisplayNames,
}
}
TabComponent={Tab}
contentContainerStyle={[
{
// visual alignment
paddingLeft: a.px_md.paddingLeft,
},
]}
/>
</BlockDrawerGesture>
)
}
let Tab = ({
onSelectTab,
interest,
active,
index,
interestsDisplayName,
onLayout,
}: {
onSelectTab: (index: number) => void
interest: string
active: boolean
index: number
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
onPress={() => onSelectTab(index)}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
a.font_medium,
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
Tab = memo(Tab)
/**
* Profile card for suggested accounts. Note: border is on the bottom edge
*/
@@ -11,6 +11,7 @@ import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
export function useSuggestedUsers({
category = null,
search = false,
overrideInterests,
}: {
category?: string | null
/**
@@ -18,11 +19,17 @@ export function useSuggestedUsers({
* based on the user's "app language setting
*/
search?: boolean
/**
* In onboarding, interests haven't been saved to prefs yet, so we need to
* pass them down through here
*/
overrideInterests?: string[]
}) {
const interestsDisplayNames = useInterestsDisplayNames()
const curated = useGetSuggestedUsersQuery({
enabled: !search,
category,
overrideInterests,
})
const searched = useActorSearchPaginated({
enabled: !!search,
@@ -43,6 +50,7 @@ export function useSuggestedUsers({
isLoading: searched.isLoading,
error: searched.error,
isRefetching: searched.isRefetching,
refetch: searched.refetch,
}
} else {
return {
@@ -50,6 +58,7 @@ export function useSuggestedUsers({
isLoading: curated.isLoading,
error: curated.error,
isRefetching: curated.isRefetching,
refetch: curated.refetch,
}
}
}, [curated, searched, search])