Value prop screen - polish, convert to pager (#9133)

* turn value prop screen into a pager on native

* rm ts-ingore

* fix pager swipe on android

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-10-10 22:05:22 +03:00
committed by GitHub
parent d528ae791f
commit 508fa2aced
12 changed files with 306 additions and 168 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 125 KiB

+7 -10
View File
@@ -5,7 +5,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {isAndroid, isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell'
import {Context} from '#/screens/Onboarding/state'
import {
@@ -59,13 +59,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
aria-label={dialogLabel}
accessibilityLabel={dialogLabel}
accessibilityHint={_(msg`Customizes your Bluesky experience`)}
style={[
// @ts-ignore web only -prf
isWeb ? a.fixed : a.absolute,
a.inset_0,
a.flex_1,
t.atoms.bg,
]}>
style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
{__DEV__ && (
<Button
variant="ghost"
@@ -135,7 +129,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
<ScrollView
ref={scrollview}
style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
contentContainerStyle={{borderWidth: 0}}
contentContainerStyle={{borderWidth: 0, minHeight: '100%'}}
showsVerticalScrollIndicator={!isAndroid}
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
// @ts-expect-error web only --prf
dataSet={{'stable-gutters': 1}}>
@@ -173,7 +168,9 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
</View>
</View>
<View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View>
<View style={[a.w_full, a.h_full, a.mb_5xl, a.pt_md]}>
{children}
</View>
<View style={{height: 100 + footerHeight}} />
</View>
@@ -0,0 +1,55 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
export function useValuePropText(step: 0 | 1 | 2) {
const {_} = useLingui()
return [
{
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`,
),
},
][step]
}
export function Dot({active}: {active: boolean}) {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
{width: 8, height: 8},
active
? {backgroundColor: t.palette.primary_500}
: t.atoms.bg_contrast_50,
]}
/>
)
}
@@ -0,0 +1,127 @@
import {useRef, useState} from 'react'
import {View} from 'react-native'
import PagerView from 'react-native-pager-view'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {PROP_1, PROP_2, PROP_3} from './images'
import {Dot, useValuePropText} from './ValuePropositionPager.shared'
export function ValuePropositionPager({
step,
setStep,
avatarUri,
}: {
step: 0 | 1 | 2
setStep: (step: 0 | 1 | 2) => void
avatarUri?: string
}) {
const t = useTheme()
const [activePage, setActivePage] = useState(step)
const ref = useRef<PagerView>(null)
if (step !== activePage) {
setActivePage(step)
ref.current?.setPage(step)
}
const images = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]]
return (
<View style={[a.h_full, {marginHorizontal: tokens.space.xl * -1}]}>
<PagerView
ref={ref}
style={[a.flex_1]}
initialPage={step}
onPageSelected={evt => {
const page = evt.nativeEvent.position as 0 | 1 | 2
if (step !== page) {
setActivePage(page)
setStep(page)
}
}}>
{([0, 1, 2] as const).map(page => (
<Page
key={page}
page={page}
image={images[page]}
avatarUri={avatarUri}
/>
))}
</PagerView>
</View>
)
}
function Page({
page,
image,
avatarUri,
}: {
page: 0 | 1 | 2
image: string
avatarUri?: string
}) {
const {_} = useLingui()
const t = useTheme()
const {title, description, alt} = useValuePropText(page)
return (
<View key={page}>
<View
style={[
a.relative,
a.align_center,
a.justify_center,
a.pointer_events_none,
]}>
<Image
source={image}
style={[a.w_full, a.aspect_square]}
alt={alt}
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
/>
{page === 1 && (
<Image
source={avatarUri}
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.px_xl, a.align_center]}>
<View style={[a.flex_row, a.gap_sm]}>
<Dot active={page === 0} />
<Dot active={page === 1} />
<Dot active={page === 2} />
</View>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, 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>
</View>
)
}
@@ -0,0 +1,80 @@
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {PROP_1, PROP_2, PROP_3} from './images'
import {Dot, useValuePropText} from './ValuePropositionPager.shared'
export function ValuePropositionPager({
step,
avatarUri,
}: {
step: 0 | 1 | 2
avatarUri?: string
}) {
const t = useTheme()
const {_} = useLingui()
const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][step]
const {title, description, alt} = useValuePropText(step)
return (
<View>
<View
style={[
a.relative,
a.align_center,
a.justify_center,
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
/>
{step === 1 && (
<Image
source={avatarUri}
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={step === 0} />
<Dot active={step === 1} />
<Dot active={step === 2} />
</View>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, 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>
</View>
)
}
@@ -0,0 +1,28 @@
import {platform} from '#/alf'
export 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
export 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
export 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
@@ -1,12 +1,5 @@
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,
@@ -29,7 +22,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 {isWeb} from '#/platform/detection'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {getAllListMembers} from '#/state/queries/list-members'
import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -49,14 +42,7 @@ import {
} 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 {atoms as a, 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'
@@ -67,6 +53,7 @@ import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() {
const {state, dispatch} = useContext(Context)
@@ -275,33 +262,6 @@ export function StepFinished() {
)
}
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,
@@ -312,12 +272,9 @@ function ValueProposition({
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
@@ -330,36 +287,6 @@ function ValueProposition({
}
}
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 && (
@@ -382,75 +309,15 @@ function ValueProposition({
</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, a.aspect_square]}
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_bold, 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>
<ValuePropositionPager
step={subStep}
setStep={ss => setSubStep(ss)}
avatarUri={state.profileStepResults.imageUri}
/>
<OnboardingControls.Portal>
<View style={gtMobile && [a.gap_md, a.flex_row]}>
{gtMobile && (
{gtMobile && (isWeb ? subStep !== 2 : true) && (
<Button
disabled={saving}
color="secondary"
@@ -492,22 +359,6 @@ function ValueProposition({
)
}
function Dot({active}: {active: boolean}) {
const t = useTheme()
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,