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
+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
}
}