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:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user