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
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+28
View File
@@ -70,9 +70,21 @@ export const atoms = {
overflow_visible: {
overflow: 'visible',
},
overflow_x_visible: {
overflowX: 'visible',
},
overflow_y_visible: {
overflowY: 'visible',
},
overflow_hidden: {
overflow: 'hidden',
},
overflow_x_hidden: {
overflowX: 'hidden',
},
overflow_y_hidden: {
overflowY: 'hidden',
},
/**
* @platform web
*/
@@ -363,6 +375,14 @@ export const atoms = {
border_r_0: {
borderRightWidth: 0,
},
border_x_0: {
borderLeftWidth: 0,
borderRightWidth: 0,
},
border_y_0: {
borderTopWidth: 0,
borderBottomWidth: 0,
},
border: {
borderWidth: StyleSheet.hairlineWidth,
},
@@ -378,6 +398,14 @@ export const atoms = {
border_r: {
borderRightWidth: StyleSheet.hairlineWidth,
},
border_x: {
borderLeftWidth: StyleSheet.hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
},
border_y: {
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
border_transparent: {
borderColor: 'transparent',
},
+390
View File
@@ -0,0 +1,390 @@
import {useEffect, useRef, useState} from 'react'
import {
type ScrollView,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isWeb} from '#/platform/detection'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {Button, ButtonIcon} from '#/components/Button'
import {
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
} from '#/components/icons/Arrow'
import {Text} from '#/components/Typography'
/**
* Tab component that automatically scrolls the selected tab into view - used for interests
* in the Find Follows dialog, Explore screen, etc.
*/
export function InterestTabs({
onSelectTab,
interests,
selectedInterest,
disabled,
interestsDisplayNames,
TabComponent = Tab,
contentContainerStyle,
gutterWidth = tokens.space.lg,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
interestsDisplayNames: Record<string, string>
/** still allows changing tab, but removes the active state from the selected tab */
disabled?: boolean
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
contentContainerStyle?: StyleProp<ViewStyle>
gutterWidth?: number
}) {
const t = useTheme()
const {_} = useLingui()
const listRef = useRef<ScrollView>(null)
const [totalWidth, setTotalWidth] = useState(0)
const [scrollX, setScrollX] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
const onInitialLayout = useNonReactiveCallback(() => {
const index = interests.indexOf(selectedInterest)
scrollIntoViewIfNeeded(index)
})
useEffect(() => {
if (tabOffsets) {
onInitialLayout()
}
}, [tabOffsets, onInitialLayout])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
const tab = interests[index]
onSelectTab(tab)
scrollIntoViewIfNeeded(index)
}
function handleTabLayout(index: number, x: number, width: number) {
if (!tabOffsets.length) {
pendingTabOffsets.current[index] = {x, width}
if (pendingTabOffsets.current.length === interests.length) {
setTabOffsets(pendingTabOffsets.current)
}
}
}
const canScrollLeft = scrollX > 0
const canScrollRight = scrollX < contentWidth - totalWidth
const cleanupRef = useRef<(() => void) | null>(null)
function scrollLeft() {
if (isContinuouslyScrollingRef.current) {
return
}
if (listRef.current && canScrollLeft) {
const newScrollX = Math.max(0, scrollX - 200)
listRef.current.scrollTo({x: newScrollX, animated: true})
}
}
function scrollRight() {
if (isContinuouslyScrollingRef.current) {
return
}
if (listRef.current && canScrollRight) {
const maxScroll = contentWidth - totalWidth
const newScrollX = Math.min(maxScroll, scrollX + 200)
listRef.current.scrollTo({x: newScrollX, animated: true})
}
}
const isContinuouslyScrollingRef = useRef(false)
function startContinuousScroll(direction: 'left' | 'right') {
// Clear any existing continuous scroll
if (cleanupRef.current) {
cleanupRef.current()
}
let holdTimeout: NodeJS.Timeout | null = null
let animationFrame: number | null = null
let isActive = true
isContinuouslyScrollingRef.current = false
const cleanup = () => {
isActive = false
if (holdTimeout) clearTimeout(holdTimeout)
if (animationFrame) cancelAnimationFrame(animationFrame)
cleanupRef.current = null
// Reset flag after a delay to prevent onPress from firing
setTimeout(() => {
isContinuouslyScrollingRef.current = false
}, 100)
}
cleanupRef.current = cleanup
// Start continuous scrolling after hold delay
holdTimeout = setTimeout(() => {
if (!isActive) return
isContinuouslyScrollingRef.current = true
let currentScrollPosition = scrollX
const scroll = () => {
if (!isActive || !listRef.current) return
const scrollAmount = 3
const maxScroll = contentWidth - totalWidth
let newScrollX: number
let canContinue = false
if (direction === 'left' && currentScrollPosition > 0) {
newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
canContinue = newScrollX > 0
} else if (direction === 'right' && currentScrollPosition < maxScroll) {
newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
canContinue = newScrollX < maxScroll
} else {
return
}
currentScrollPosition = newScrollX
listRef.current.scrollTo({x: newScrollX, animated: false})
if (canContinue && isActive) {
animationFrame = requestAnimationFrame(scroll)
}
}
scroll()
}, 500)
}
function stopContinuousScroll() {
if (cleanupRef.current) {
cleanupRef.current()
}
}
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current()
}
}
}, [])
return (
<View style={[a.relative, a.flex_row]}>
<DraggableScrollView
ref={listRef}
contentContainerStyle={[
a.gap_sm,
{paddingHorizontal: gutterWidth},
contentContainerStyle,
]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
tabOffsets.length === interests.length
? tabOffsets.map(o => o.x - tokens.space.xl)
: undefined
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
onContentSizeChange={width => setContentWidth(width)}
onScroll={evt => {
const newScrollX = evt.nativeEvent.contentOffset.x
setScrollX(newScrollX)
}}
scrollEventThrottle={16}>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !disabled
return (
<TabComponent
key={interest}
onSelectTab={handleSelectTab}
active={active}
index={i}
interest={interest}
interestsDisplayName={interestsDisplayNames[interest]}
onLayout={handleTabLayout}
/>
)
})}
</DraggableScrollView>
{isWeb && canScrollLeft && (
<View
style={[
a.absolute,
a.top_0,
a.left_0,
a.bottom_0,
a.justify_center,
{paddingLeft: gutterWidth},
a.pr_md,
a.z_10,
web({
background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
}),
]}>
<Button
label={_(msg`Scroll left`)}
onPress={scrollLeft}
onPressIn={() => startContinuousScroll('left')}
onPressOut={stopContinuousScroll}
color="secondary"
size="small"
style={[
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.h_full,
{aspectRatio: 1},
a.rounded_full,
]}>
<ButtonIcon icon={ArrowLeft} />
</Button>
</View>
)}
{isWeb && canScrollRight && (
<View
style={[
a.absolute,
a.top_0,
a.right_0,
a.bottom_0,
a.justify_center,
{paddingRight: gutterWidth},
a.pl_md,
a.z_10,
web({
background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
}),
]}>
<Button
label={_(msg`Scroll right`)}
onPress={scrollRight}
onPressIn={() => startContinuousScroll('right')}
onPressOut={stopContinuousScroll}
color="secondary"
size="small"
style={[
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.h_full,
{aspectRatio: 1},
a.rounded_full,
]}>
<ButtonIcon icon={ArrowRight} />
</Button>
</View>
)}
</View>
)
}
function 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
}) {
const t = useTheme()
const {_} = useLingui()
const label = active
? _(
msg({
message: `"${interestsDisplayName}" category (active)`,
comment:
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.',
}),
)
: _(
msg({
message: `Select "${interestsDisplayName}" category`,
comment:
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.',
}),
)
return (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={label}
onPress={() => onSelectTab(index)}
// disable focus ring, we handle it
style={web({outline: 'none'})}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
: focused
? {
borderColor: t.palette.primary_300,
backgroundColor: t.palette.primary_25,
}
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
a.font_medium,
active || hovered || pressed
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
const rankA = indexA === -1 ? Infinity : indexA
const rankB = indexB === -1 ? Infinity : indexB
return rankA - rankB
}
}
+24 -122
View File
@@ -1,17 +1,9 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
ScrollView,
type StyleProp,
TextInput,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {TextInput, useWindowDimensions, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -28,7 +20,6 @@ import {
import {
atoms as a,
native,
tokens,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -40,6 +31,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
@@ -337,12 +329,13 @@ let Header = ({
}}
onEscape={control.close}
/>
<Tabs
<InterestTabs
onSelectTab={onSelectTab}
interests={interests}
selectedInterest={selectedInterest}
hasSearchText={!!searchText}
disabled={!!searchText}
interestsDisplayNames={interestsDisplayNames}
TabComponent={Tab}
/>
</View>
</View>
@@ -403,99 +396,6 @@ function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
)
}
let Tabs = ({
onSelectTab,
interests,
selectedInterest,
hasSearchText,
interestsDisplayNames,
TabComponent = Tab,
contentContainerStyle,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
hasSearchText: boolean
interestsDisplayNames: Record<string, string>
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
contentContainerStyle?: StyleProp<ViewStyle>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [totalWidth, setTotalWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
const onInitialLayout = useNonReactiveCallback(() => {
const index = interests.indexOf(selectedInterest)
scrollIntoViewIfNeeded(index)
})
useEffect(() => {
if (tabOffsets) {
onInitialLayout()
}
}, [tabOffsets, onInitialLayout])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
const tab = interests[index]
onSelectTab(tab)
scrollIntoViewIfNeeded(index)
}
function handleTabLayout(index: number, x: number, width: number) {
if (!tabOffsets.length) {
pendingTabOffsets.current[index] = {x, width}
if (pendingTabOffsets.current.length === interests.length) {
setTabOffsets(pendingTabOffsets.current)
}
}
}
return (
<ScrollView
ref={listRef}
horizontal
contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
tabOffsets.length === interests.length
? tabOffsets.map(o => o.x - tokens.space.xl)
: undefined
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
scrollEventThrottle={200} // big throttle
>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
<TabComponent
key={interest}
onSelectTab={handleSelectTab}
active={active}
index={i}
interest={interest}
interestsDisplayName={interestsDisplayNames[interest]}
onLayout={handleTabLayout}
/>
)
})}
</ScrollView>
)
}
Tabs = memo(Tabs)
export {Tabs}
let Tab = ({
onSelectTab,
interest,
@@ -513,24 +413,36 @@ let Tab = ({
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
const label = active
? _(
msg({
message: `Search for "${interestsDisplayName}" (active)`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.',
}),
)
: _(
msg({
message: `Search for "${interestsDisplayName}`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.',
}),
)
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}) => (
<Button label={label} onPress={() => onSelectTab(index)}>
{({hovered, pressed}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
active || hovered || pressed
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
@@ -540,7 +452,7 @@ let Tab = ({
<Text
style={[
a.font_medium,
active || hovered || pressed || focused
active || hovered || pressed
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
@@ -759,13 +671,3 @@ function Empty({message}: {message: string}) {
</View>
)
}
export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
const rankA = indexA === -1 ? Infinity : indexA
const rankB = indexB === -1 ? Infinity : indexB
return rankA - rankB
}
}
+3 -3
View File
@@ -20,9 +20,6 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
return
}
const slider = ref.current as unknown as HTMLDivElement
if (!slider) {
return
}
let isDragging = false
let isMouseDown = false
let startX = 0
@@ -61,6 +58,9 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
e.preventDefault()
const walk = x - startX
slider.scrollLeft = scrollLeft - walk
if (slider.contains(document.activeElement))
(document.activeElement as HTMLElement)?.blur?.()
}
slider.addEventListener('mousedown', mouseDown)
+2
View File
@@ -9,6 +9,8 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'onboarding_suggested_accounts'
| 'onboarding_value_prop'
| 'post_follow_profile_suggested_accounts'
| 'remove_show_latest_button'
| 'test_gate_1'
+18 -1
View File
@@ -90,6 +90,13 @@ export type MetricEvents = {
selectedInterests: string[]
selectedInterestsLength: number
}
'onboarding:suggestedAccounts:tabPressed': {
tab: string
}
'onboarding:suggestedAccounts:followAllPressed': {
tab: string
numAccounts: number
}
'onboarding:suggestedAccounts:nextPressed': {
selectedAccountsLength: number
skipped: boolean
@@ -118,6 +125,9 @@ export type MetricEvents = {
'onboarding:finished:avatarResult': {
avatarResult: 'default' | 'created' | 'uploaded'
}
'onboarding:valueProp:stepOne:nextPressed': {}
'onboarding:valueProp:stepTwo:nextPressed': {}
'onboarding:valueProp:skipPressed': {}
'home:feedDisplayed': {
feedUrl: string
feedType: string
@@ -242,6 +252,7 @@ export type MetricEvents = {
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
}
'suggestedUser:follow': {
logContext:
@@ -249,12 +260,17 @@ export type MetricEvents = {
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile'
recId?: number
position: number
}
'suggestedUser:press': {
logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile'
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Onboarding'
recId?: number
position: number
}
@@ -280,6 +296,7 @@ export type MetricEvents = {
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+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])
@@ -17,6 +17,7 @@ export type QueryProps = {
category?: string | null
limit?: number
enabled?: boolean
overrideInterests?: string[]
}
export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users'
@@ -24,6 +25,7 @@ export const createGetSuggestedUsersQueryKey = (props: QueryProps) => [
getSuggestedUsersQueryKeyRoot,
props.category,
props.limit,
props.overrideInterests?.join(','),
]
export function useGetSuggestedUsersQuery(props: QueryProps) {
@@ -36,6 +38,7 @@ export function useGetSuggestedUsersQuery(props: QueryProps) {
queryKey: createGetSuggestedUsersQueryKey(props),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const interests = aggregateUserInterests(preferences)
const {data} = await agent.app.bsky.unspecced.getSuggestedUsers(
{
category: props.category ?? undefined,
@@ -43,7 +46,11 @@ export function useGetSuggestedUsersQuery(props: QueryProps) {
},
{
headers: {
...createBskyTopicsHeader(aggregateUserInterests(preferences)),
...createBskyTopicsHeader(
props.overrideInterests && props.overrideInterests.length > 0
? props.overrideInterests.join(',')
: interests,
),
'Accept-Language': contentLangs,
},
},