Remove old onboarding (#4224)

* Hardcode onboarding_v2 to true, rm dead code

* Rm initialState, use initialStateReduced

* Rm dead code

* Drop *reduced prefix in code

* Prettier
zio/stable
dan 2024-05-28 16:56:06 +01:00 committed by GitHub
parent 9bd411c151
commit adbbded003
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 27 additions and 1986 deletions

View File

@ -1,5 +1,4 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'reduced_onboarding_and_home_algo_v2'
| 'request_notifications_permission_after_onboarding' | 'request_notifications_permission_after_onboarding'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'

View File

@ -1,378 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
function PrimaryFeedCardInner({
feed,
config,
}: {
feed: FeedSourceInfo
config: FeedConfig
}) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const styles = React.useMemo(
() => ({
active: [t.atoms.bg_contrast_25],
selected: [
a.shadow_md,
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
},
],
selectedHover: [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
},
],
textSelected: [{color: t.palette.white}],
checkboxSelected: [
{
borderColor: t.palette.white,
},
],
}),
[t],
)
return (
<View
style={[
a.relative,
a.w_full,
a.p_lg,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
ctx.selected && styles.selected,
ctx.selected &&
(ctx.hovered || ctx.focused || ctx.pressed) &&
styles.selectedHover,
]}>
{ctx.selected && config.gradient && (
<LinearGradient
colors={config.gradient.values.map(v => v[1])}
locations={config.gradient.values.map(v => v[0])}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
)}
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
<View
style={[
{
width: 64,
height: 64,
},
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg,
]}>
<Image
source={{uri: feed.avatar}}
style={[a.w_full, a.h_full]}
accessibilityIgnoresInvertColors
/>
</View>
<View style={[a.pt_xs, a.flex_grow]}>
<Text
style={[
a.text_lg,
a.font_bold,
ctx.selected && styles.textSelected,
]}>
{feed.displayName}
</Text>
<Text
style={[
{opacity: 0.6},
a.text_md,
a.py_xs,
ctx.selected && styles.textSelected,
]}>
<Trans>by @{feed.creatorHandle}</Trans>
</Text>
</View>
<View
style={[
{
width: 28,
height: 28,
},
a.justify_center,
a.align_center,
a.rounded_sm,
ctx.selected ? [a.border, styles.checkboxSelected] : t.atoms.bg,
]}>
{ctx.selected && <Check size="sm" fill={t.palette.white} />}
</View>
</View>
<View
style={[
{
opacity: ctx.selected ? 0.3 : 1,
borderTopWidth: 1,
},
a.mt_md,
a.w_full,
t.atoms.border_contrast_low,
ctx.selected && {
borderTopColor: t.palette.white,
},
]}
/>
<View style={[a.pt_md]}>
<RichText
value={feed.description}
style={[
a.text_md,
ctx.selected &&
(t.name === 'light'
? t.atoms.text_inverted
: {color: t.palette.white}),
]}
disableLinks
/>
</View>
</View>
)
}
export function PrimaryFeedCard({config}: {config: FeedConfig}) {
const {_} = useLingui()
const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
return !feed ? (
<FeedCardPlaceholder primary />
) : (
<Toggle.Item
name={feed.uri}
label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
<PrimaryFeedCardInner config={config} feed={feed} />
</Toggle.Item>
)
}
function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const styles = React.useMemo(
() => ({
active: [t.atoms.bg_contrast_25],
selected: [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
},
],
selectedHover: [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
},
],
textSelected: [],
checkboxSelected: [
{
backgroundColor: t.palette.primary_500,
},
],
}),
[t],
)
return (
<View
style={[
a.relative,
a.w_full,
a.p_md,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
ctx.selected && styles.selected,
ctx.selected &&
(ctx.hovered || ctx.focused || ctx.pressed) &&
styles.selectedHover,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
<View
style={[
{
width: 44,
height: 44,
},
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg,
]}>
<Image
source={{uri: feed.avatar}}
style={[a.w_full, a.h_full]}
accessibilityIgnoresInvertColors
/>
</View>
<View style={[a.pt_2xs, a.flex_1, a.flex_grow]}>
<Text
style={[
a.text_md,
a.font_bold,
ctx.selected && styles.textSelected,
]}
numberOfLines={1}>
{feed.displayName}
</Text>
<Text
style={[
{opacity: 0.8},
a.pt_xs,
ctx.selected && styles.textSelected,
]}>
@{feed.creatorHandle}
</Text>
</View>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_sm,
t.atoms.bg,
ctx.selected && styles.checkboxSelected,
{
width: 28,
height: 28,
},
]}>
{ctx.selected && <Check size="sm" fill={t.palette.white} />}
</View>
</View>
<View
style={[
{
opacity: ctx.selected ? 0.3 : 1,
borderTopWidth: 1,
},
a.mt_md,
a.w_full,
t.atoms.border_contrast_low,
ctx.selected && {
borderTopColor: t.palette.primary_200,
},
]}
/>
<View style={[a.pt_md]}>
<RichText value={feed.description} disableLinks />
</View>
</View>
)
}
export function FeedCard({config}: {config: FeedConfig}) {
const {_} = useLingui()
const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
return !feed ? (
<FeedCardPlaceholder />
) : feed.avatar ? (
<Toggle.Item
name={feed.uri}
label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
<FeedCardInner config={config} feed={feed} />
</Toggle.Item>
) : null
}
export function FeedCardPlaceholder({primary}: {primary?: boolean}) {
const t = useTheme()
return (
<View
style={[
a.relative,
a.w_full,
a.p_md,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
<View
style={[
{
width: primary ? 64 : 44,
height: primary ? 64 : 44,
},
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg_contrast_100,
]}
/>
<View style={[a.pt_2xs, a.flex_grow, a.gap_sm]}>
<View
style={[
{width: 100, height: primary ? 20 : 16},
a.rounded_sm,
t.atoms.bg_contrast_100,
]}
/>
<View
style={[
{width: 60, height: 12},
a.rounded_sm,
t.atoms.bg_contrast_100,
]}
/>
</View>
</View>
<View
style={[
{
borderTopWidth: 1,
},
a.mt_md,
a.w_full,
t.atoms.border_contrast_low,
]}
/>
<View style={[a.pt_md, a.gap_xs]}>
<View
style={[
{width: '60%', height: 12},
a.rounded_sm,
t.atoms.bg_contrast_100,
]}
/>
</View>
</View>
)
}

View File

@ -1,168 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_PROD} from '#/env'
export type FeedConfig = {
default: boolean
uri: string
gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic
}
export const PRIMARY_FEEDS: FeedConfig[] = [
{
default: IS_PROD, // these feeds are only available in prod
uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot',
gradient: tokens.gradients.midnight,
},
]
const SECONDARY_FEEDS: FeedConfig[] = [
{
default: false,
uri: 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq',
},
{
default: false,
uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends',
},
{
default: false,
uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows',
},
{
default: false,
uri: 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/catch-up',
},
{
default: false,
uri: 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/at-bangers',
},
]
export function StepAlgoFeeds() {
const {_} = useLingui()
const {track} = useAnalytics()
const t = useTheme()
const {state, dispatch} = React.useContext(Context)
const [primaryFeedUris, setPrimaryFeedUris] = React.useState<string[]>(
PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean),
)
const [secondaryFeedUris, setSeconaryFeedUris] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false)
const saveFeeds = React.useCallback(async () => {
setSaving(true)
const uris = primaryFeedUris.concat(secondaryFeedUris)
dispatch({type: 'setAlgoFeedsStepResults', feedUris: uris})
setSaving(false)
dispatch({type: 'next'})
track('OnboardingV2:StepAlgoFeeds:End', {
selectedPrimaryFeeds: primaryFeedUris,
selectedPrimaryFeedsLength: primaryFeedUris.length,
selectedSecondaryFeeds: secondaryFeedUris,
selectedSecondaryFeedsLength: secondaryFeedUris.length,
})
logEvent('onboarding:algoFeeds:nextPressed', {
selectedPrimaryFeeds: primaryFeedUris,
selectedPrimaryFeedsLength: primaryFeedUris.length,
selectedSecondaryFeeds: secondaryFeedUris,
selectedSecondaryFeedsLength: secondaryFeedUris.length,
})
}, [primaryFeedUris, secondaryFeedUris, dispatch, track])
React.useEffect(() => {
track('OnboardingV2:StepAlgoFeeds:Start')
}, [track])
return (
<View style={[a.align_start]}>
<IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
<TitleText>
<Trans>Choose your main feeds</Trans>
</TitleText>
<DescriptionText>
<Trans>
Custom feeds built by the community bring you new experiences and help
you find the content you love.
</Trans>
</DescriptionText>
<View style={[a.w_full, a.pb_2xl]}>
<Toggle.Group
values={primaryFeedUris}
onChange={setPrimaryFeedUris}
label={_(msg`Select your primary algorithmic feeds`)}>
<Text
style={[
a.text_md,
a.pt_4xl,
a.pb_md,
t.atoms.text_contrast_medium,
]}>
<Trans>We recommend our "Discover" feed:</Trans>
</Text>
<FeedCard config={PRIMARY_FEEDS[0]} />
</Toggle.Group>
<Toggle.Group
values={secondaryFeedUris}
onChange={setSeconaryFeedUris}
label={_(msg`Select your secondary algorithmic feeds`)}>
<Text
style={[
a.text_md,
a.pt_4xl,
a.pb_lg,
t.atoms.text_contrast_medium,
]}>
<Trans>There are many feeds to try:</Trans>
</Text>
<View style={[a.gap_md]}>
{SECONDARY_FEEDS.map(config => (
<FeedCard key={config.uri} config={config} />
))}
</View>
</Toggle.Group>
</View>
<OnboardingControls.Portal>
<Button
disabled={saving}
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to the next step`)}
onPress={saveFeeds}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
</Button>
</OnboardingControls.Portal>
</View>
)
}

View File

@ -1,19 +1,14 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {TID} from '@atproto/common-web'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants' import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {preferencesQueryKey} from '#/state/queries/preferences'
preferencesQueryKey,
useOverwriteSavedFeedsMutation,
} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile' import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
@ -24,10 +19,7 @@ import {
TitleText, TitleText,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {Context} from '#/screens/Onboarding/state'
import { import {bulkWriteFollows} from '#/screens/Onboarding/util'
bulkWriteFollows,
sortPrimaryAlgorithmFeeds,
} from '#/screens/Onboarding/util'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle' import {IconCircle} from '#/components/IconCircle'
@ -45,83 +37,21 @@ export function StepFinished() {
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = React.useContext(Context)
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const finishOnboarding = React.useCallback(async () => { const finishOnboarding = React.useCallback(async () => {
setSaving(true) setSaving(true)
// TODO uncomment const {interestsStepResults, profileStepResults} = state
const {
interestsStepResults,
suggestedAccountsStepResults,
algoFeedsStepResults,
topicalFeedsStepResults,
profileStepResults,
} = state
const {selectedInterests} = interestsStepResults const {selectedInterests} = interestsStepResults
const selectedFeeds = [
...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris),
...topicalFeedsStepResults.feedUris,
]
try { try {
await Promise.all([ await Promise.all([
bulkWriteFollows( bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]),
agent,
suggestedAccountsStepResults.accountDids.concat(BSKY_APP_ACCOUNT_DID),
),
// these must be serial
(async () => { (async () => {
await agent.setInterestsPref({tags: selectedInterests}) await agent.setInterestsPref({tags: selectedInterests})
/*
* In the reduced onboading experiment, we'll rely on the default
* feeds set in `createAgentAndCreateAccount`. No feeds will be
* selected in onboarding and therefore we don't need to run this
* code (which would overwrite the other feeds already set).
*/
if (!gate('reduced_onboarding_and_home_algo_v2')) {
const otherFeeds = selectedFeeds.length
? selectedFeeds.map(f => ({
type: 'feed',
value: f,
pinned: true,
id: TID.nextStr(),
}))
: []
/*
* If no selected feeds and we're in prod, add the discover feed
* (mimics old behavior)
*/
if (
IS_PROD_SERVICE(agent.service.toString()) &&
!otherFeeds.length
) {
otherFeeds.push({
...DISCOVER_SAVED_FEED,
pinned: true,
id: TID.nextStr(),
})
}
await overwriteSavedFeeds([
{
...TIMELINE_SAVED_FEED,
pinned: true,
id: TID.nextStr(),
},
...otherFeeds,
])
}
})(), })(),
(async () => { (async () => {
if (!gate('reduced_onboarding_and_home_algo_v2')) return
const {imageUri, imageMime} = profileStepResults const {imageUri, imageMime} = profileStepResults
if (imageUri && imageMime) { if (imageUri && imageMime) {
const blobPromise = uploadBlob(agent, imageUri, imageMime) const blobPromise = uploadBlob(agent, imageUri, imageMime)
@ -134,7 +64,6 @@ export function StepFinished() {
return existing return existing
}) })
} }
logEvent('onboarding:finished:avatarResult', { logEvent('onboarding:finished:avatarResult', {
avatarResult: profileStepResults.isCreatedAvatar avatarResult: profileStepResults.isCreatedAvatar
? 'created' ? 'created'
@ -169,17 +98,7 @@ export function StepFinished() {
track('OnboardingV2:StepFinished:End') track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete') track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {}) logEvent('onboarding:finished:nextPressed', {})
}, [ }, [state, dispatch, onboardDispatch, setSaving, track, agent, queryClient])
state,
dispatch,
onboardDispatch,
setSaving,
overwriteSavedFeeds,
track,
agent,
gate,
queryClient,
])
React.useEffect(() => { React.useEffect(() => {
track('OnboardingV2:StepFinished:Start') track('OnboardingV2:StepFinished:Start')

View File

@ -1,161 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
} from 'state/queries/preferences'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {Text} from '#/components/Typography'
export function StepFollowingFeed() {
const {_} = useLingui()
const {track} = useAnalytics()
const {dispatch} = React.useContext(Context)
const {data: preferences} = usePreferencesQuery()
const {mutate: setFeedViewPref, variables} =
useSetFeedViewPreferencesMutation()
const showReplies = !(
variables?.hideReplies ?? preferences?.feedViewPrefs.hideReplies
)
const showReposts = !(
variables?.hideReposts ?? preferences?.feedViewPrefs.hideReposts
)
const showQuotes = !(
variables?.hideQuotePosts ?? preferences?.feedViewPrefs.hideQuotePosts
)
const onContinue = React.useCallback(() => {
dispatch({type: 'next'})
track('OnboardingV2:StepFollowingFeed:End')
logEvent('onboarding:followingFeed:nextPressed', {})
}, [track, dispatch])
React.useEffect(() => {
track('OnboardingV2:StepFollowingFeed:Start')
}, [track])
return (
// Hack for now to move the image container up
<View style={[a.align_start]}>
<IconCircle icon={FilterTimeline} style={[a.mb_2xl]} />
<TitleText>
<Trans>Your default feed is "Following"</Trans>
</TitleText>
<DescriptionText style={[a.mb_md]}>
<Trans>It shows posts from the people you follow as they happen.</Trans>
</DescriptionText>
<View style={[a.w_full]}>
<Toggle.Item
name="Show Replies" // no need to translate
label={_(msg`Show replies in Following feed`)}
value={showReplies}
onChange={() => {
setFeedViewPref({
hideReplies: showReplies,
})
}}>
<View
style={[
a.flex_row,
a.w_full,
a.py_lg,
a.justify_between,
a.align_center,
]}>
<Text style={[a.text_md, a.font_bold]}>
<Trans>Show replies in Following</Trans>
</Text>
<Toggle.Switch />
</View>
</Toggle.Item>
<Divider />
<Toggle.Item
name="Show Reposts" // no need to translate
label={_(msg`Show re-posts in Following feed`)}
value={showReposts}
onChange={() => {
setFeedViewPref({
hideReposts: showReposts,
})
}}>
<View
style={[
a.flex_row,
a.w_full,
a.py_lg,
a.justify_between,
a.align_center,
]}>
<Text style={[a.text_md, a.font_bold]}>
<Trans>Show reposts in Following</Trans>
</Text>
<Toggle.Switch />
</View>
</Toggle.Item>
<Divider />
<Toggle.Item
name="Show Quotes" // no need to translate
label={_(msg`Show quote-posts in Following feed`)}
value={showQuotes}
onChange={() => {
setFeedViewPref({
hideQuotePosts: showQuotes,
})
}}>
<View
style={[
a.flex_row,
a.w_full,
a.py_lg,
a.justify_between,
a.align_center,
]}>
<Text style={[a.text_md, a.font_bold]}>
<Trans>Show quotes in Following</Trans>
</Text>
<Toggle.Switch />
</View>
</Toggle.Item>
</View>
<DescriptionText style={[a.mt_lg]}>
<Trans>You can change these settings later.</Trans>
</DescriptionText>
<OnboardingControls.Portal>
<Button
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to next step`)}
onPress={onContinue}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
</OnboardingControls.Portal>
</View>
)
}

View File

@ -5,12 +5,11 @@ import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize' import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import { import {
DescriptionText, DescriptionText,
OnboardingControls, OnboardingControls,
@ -34,8 +33,6 @@ export function StepInterests() {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {track} = useAnalytics() const {track} = useAnalytics()
const gate = useGate()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context) const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
@ -132,12 +129,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start') track('OnboardingV2:StepInterests:Start')
}, [track]) }, [track])
React.useEffect(() => {
if (!gate('reduced_onboarding_and_home_algo_v2')) {
requestNotificationsPermission('StartOnboarding')
}
}, [gate, requestNotificationsPermission])
const title = isError ? ( const title = isError ? (
<Trans>Oh no! Something went wrong.</Trans> <Trans>Oh no! Something went wrong.</Trans>
) : ( ) : (

View File

@ -1,131 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {UseMutateFunction} from '@tanstack/react-query'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {usePreferencesQuery} from '#/state/queries/preferences'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
function Card({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
a.px_lg,
a.py_md,
a.rounded_sm,
a.mb_md,
t.atoms.bg_contrast_50,
]}>
{children}
</View>
)
}
export function AdultContentEnabledPref({
mutate,
variables,
}: {
mutate: UseMutateFunction<void, unknown, {enabled: boolean}, unknown>
variables: {enabled: boolean} | undefined
}) {
const {_} = useLingui()
const t = useTheme()
const prompt = Prompt.usePromptControl()
// Reuse logic here form ContentFilteringSettings.tsx
const {data: preferences} = usePreferencesQuery()
const onToggleAdultContent = React.useCallback(async () => {
if (isIOS) {
prompt.open()
return
}
try {
mutate({
enabled: !(
variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
),
})
} catch (e) {
Toast.show(
_(msg`There was an issue syncing your preferences with the server`),
)
logger.error('Failed to update preferences with server', {error: e})
}
}, [variables, preferences, mutate, _, prompt])
if (!preferences) return null
return (
<>
{preferences.userAge && preferences.userAge >= 18 ? (
<View style={[a.w_full, a.px_xs]}>
<Toggle.Item
name={_(msg`Enable adult content in your feeds`)}
label={_(msg`Enable adult content in your feeds`)}
value={
variables?.enabled ??
preferences?.moderationPrefs.adultContentEnabled
}
onChange={onToggleAdultContent}>
<View
style={[
a.flex_row,
a.w_full,
a.justify_between,
a.align_center,
a.py_md,
]}>
<Text style={[a.font_bold]}>
<Trans>Enable Adult Content</Trans>
</Text>
<Toggle.Switch />
</View>
</Toggle.Item>
</View>
) : (
<Card>
<CircleInfo size="sm" fill={t.palette.contrast_500} />
<Text
style={[
a.flex_1,
t.atoms.text_contrast_medium,
a.leading_snug,
{paddingTop: 1},
]}>
<Trans>You must be 18 years or older to enable adult content</Trans>
</Text>
</Card>
)}
<Prompt.Outer control={prompt}>
<Prompt.TitleText>
<Trans>Adult Content</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Due to Apple policies, adult content can only be enabled on the web
after completing sign up.
</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action onPress={() => prompt.close()} cta={_(msg`OK`)} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}

View File

@ -1,99 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Text} from '#/components/Typography'
export function ModerationOption({
labelValueDefinition,
disabled,
}: {
labelValueDefinition: InterpretedLabelValueDefinition
disabled?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const label = labelValueDefinition.identifier
const visibility =
variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
const allLabelStrings = useGlobalLabelStrings()
const labelStrings =
labelValueDefinition.identifier in allLabelStrings
? allLabelStrings[labelValueDefinition.identifier]
: {
name: labelValueDefinition.identifier,
description: `Labeled "${labelValueDefinition.identifier}"`,
}
const onChange = React.useCallback(
(vis: string[]) => {
mutate({
label,
visibility: vis[0] as LabelPreference,
labelerDid: undefined,
})
},
[mutate, label],
)
const labels = {
hide: _(msg`Hide`),
warn: _(msg`Warn`),
show: _(msg`Show`),
}
return (
<View
style={[
a.flex_row,
a.justify_between,
a.gap_sm,
a.py_xs,
a.px_xs,
a.align_center,
]}>
<View style={[a.gap_xs, a.flex_1]}>
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
{labelStrings.description}
</Text>
</View>
<View style={[a.justify_center, {minHeight: 40}]}>
{disabled ? (
<Text style={[a.font_bold]}>
<Trans>Hide</Trans>
</Text>
) : (
<ToggleButton.Group
label={_(
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
)}
values={[visibility ?? 'hide']}
onChange={onChange}>
<ToggleButton.Button name="ignore" label={labels.show}>
<ToggleButton.ButtonText>{labels.show}</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="warn" label={labels.warn}>
<ToggleButton.ButtonText>{labels.warn}</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="hide" label={labels.hide}>
<ToggleButton.ButtonText>{labels.hide}</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
)}
</View>
</View>
)
}

View File

@ -1,110 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {LABELS} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Loader} from '#/components/Loader'
export function StepModeration() {
const {_} = useLingui()
const {track} = useAnalytics()
const {state, dispatch} = React.useContext(Context)
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetAdultContentMutation()
// We need to know if the screen is mounted so we know if we want to run entering animations
// https://github.com/software-mansion/react-native-reanimated/discussions/2513
const isMounted = React.useRef(false)
React.useLayoutEffect(() => {
isMounted.current = true
}, [])
const adultContentEnabled = !!(
(variables && variables.enabled) ||
(!variables && preferences?.moderationPrefs.adultContentEnabled)
)
const onContinue = React.useCallback(() => {
dispatch({type: 'next'})
track('OnboardingV2:StepModeration:End')
logEvent('onboarding:moderation:nextPressed', {})
}, [track, dispatch])
React.useEffect(() => {
track('OnboardingV2:StepModeration:Start')
}, [track])
return (
<View style={[a.align_start]}>
<IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
<TitleText>
<Trans>You're in control</Trans>
</TitleText>
<DescriptionText style={[a.mb_xl]}>
<Trans>
Select what you want to see (or not see), and well handle the rest.
</Trans>
</DescriptionText>
{!preferences ? (
<View style={[a.pt_md]}>
<Loader size="xl" />
</View>
) : (
<>
<AdultContentEnabledPref mutate={mutate} variables={variables} />
<View style={[a.gap_sm, a.w_full]}>
<ModerationOption
labelValueDefinition={LABELS.porn}
disabled={!adultContentEnabled}
/>
<ModerationOption
labelValueDefinition={LABELS.sexual}
disabled={!adultContentEnabled}
/>
<ModerationOption
labelValueDefinition={LABELS['graphic-media']}
disabled={!adultContentEnabled}
/>
<ModerationOption labelValueDefinition={LABELS.nudity} />
</View>
</>
)}
<OnboardingControls.Portal>
<Button
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to next step`)}
onPress={onContinue}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
</OnboardingControls.Portal>
</View>
)
}

View File

@ -92,11 +92,7 @@ export function StepProfile() {
}, [track]) }, [track])
React.useEffect(() => { React.useEffect(() => {
// We have an experiment running for redueced onboarding, where this screen shows up as the first in onboarding.
// We only want to request permissions when that gate is actually active to prevent pollution
if (gate('reduced_onboarding_and_home_algo_v2')) {
requestNotificationsPermission('StartOnboarding') requestNotificationsPermission('StartOnboarding')
}
}, [gate, requestNotificationsPermission]) }, [gate, requestNotificationsPermission])
const openPicker = React.useCallback( const openPicker = React.useCallback(

View File

@ -1,188 +0,0 @@
import React from 'react'
import {View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, flatten, useTheme} from '#/alf'
import {useItemContext} from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function SuggestedAccountCard({
profile,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ReturnType<typeof useModerationOpts>
}) {
const t = useTheme()
const ctx = useItemContext()
const moderation = moderateProfile(profile, moderationOpts!)
const styles = React.useMemo(() => {
const light = t.name === 'light'
const base: ViewStyle[] = [t.atoms.bg_contrast_50]
const hover: ViewStyle[] = [t.atoms.bg_contrast_25]
const selected: ViewStyle[] = [
{
backgroundColor: light ? t.palette.primary_50 : t.palette.primary_950,
},
]
const selectedHover: ViewStyle[] = [
{
backgroundColor: light ? t.palette.primary_25 : t.palette.primary_975,
},
]
const checkboxBase: ViewStyle[] = [t.atoms.bg]
const checkboxSelected: ViewStyle[] = [
{
backgroundColor: t.palette.primary_500,
},
]
const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100]
const avatarSelected: ViewStyle[] = [
{
backgroundColor: light ? t.palette.primary_100 : t.palette.primary_900,
},
]
return {
base,
hover: flatten(hover),
selected: flatten(selected),
selectedHover: flatten(selectedHover),
checkboxBase: flatten(checkboxBase),
checkboxSelected: flatten(checkboxSelected),
avatarBase: flatten(avatarBase),
avatarSelected: flatten(avatarSelected),
}
}, [t])
return (
<View
style={[
a.w_full,
a.p_md,
a.pr_lg,
a.gap_md,
a.rounded_md,
styles.base,
(ctx.hovered || ctx.focused || ctx.pressed) && styles.hover,
ctx.selected && styles.selected,
ctx.selected &&
(ctx.hovered || ctx.focused || ctx.pressed) &&
styles.selectedHover,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
<View style={[a.flex_row, a.flex_1, a.align_center, a.gap_md]}>
<View
style={[
{width: 48, height: 48},
a.relative,
a.rounded_full,
styles.avatarBase,
ctx.selected && styles.avatarSelected,
]}>
<UserAvatar
size={48}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
<View style={[a.flex_1]}>
<Text style={[a.font_bold, a.text_md, a.pb_xs]} numberOfLines={1}>
{profile.displayName}
</Text>
<Text style={[t.atoms.text_contrast_medium]}>{profile.handle}</Text>
</View>
</View>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_sm,
styles.checkboxBase,
ctx.selected && styles.checkboxSelected,
{
width: 28,
height: 28,
},
]}>
{ctx.selected && <Check size="sm" fill={t.palette.white} />}
</View>
</View>
{profile.description && (
<>
<View
style={[
{
opacity: ctx.selected ? 0.3 : 1,
borderTopWidth: 1,
},
a.w_full,
t.atoms.border_contrast_low,
ctx.selected && {
borderTopColor: t.palette.primary_200,
},
]}
/>
<RichText
value={profile.description}
disableLinks
numberOfLines={2}
/>
</>
)}
</View>
)
}
export function SuggestedAccountCardPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.flex_row,
a.justify_between,
a.align_center,
a.p_md,
a.pr_lg,
a.gap_xl,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View
style={[
{width: 48, height: 48},
a.relative,
a.rounded_full,
t.atoms.bg_contrast_100,
]}
/>
<View style={[a.gap_xs]}>
<View
style={[
{width: 100, height: 16},
a.rounded_sm,
t.atoms.bg_contrast_100,
]}
/>
<View
style={[
{width: 60, height: 12},
a.rounded_sm,
t.atoms.bg_contrast_100,
]}
/>
</View>
</View>
</View>
)
}

View File

@ -1,210 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
SuggestedAccountCard,
SuggestedAccountCardPlaceholder,
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Inner({
profiles,
onSelect,
moderationOpts,
}: {
profiles: AppBskyActorDefs.ProfileViewDetailed[]
onSelect: (dids: string[]) => void
moderationOpts: ReturnType<typeof useModerationOpts>
}) {
const {_} = useLingui()
const [dids, setDids] = React.useState<string[]>(profiles.map(p => p.did))
React.useEffect(() => {
onSelect(dids)
}, [dids, onSelect])
return (
<Toggle.Group
values={dids}
onChange={setDids}
label={_(msg`Select some accounts below to follow`)}>
<View style={[a.gap_md]}>
{profiles.map(profile => (
<Toggle.Item
key={profile.did}
name={profile.did}
label={_(msg`Follow ${profile.handle}`)}>
<SuggestedAccountCard
profile={profile}
moderationOpts={moderationOpts}
/>
</Toggle.Item>
))}
</View>
</Toggle.Group>
)
}
export function StepSuggestedAccounts() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const suggestedDids = React.useMemo(() => {
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedAccountDids,
state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
)
}, [state.interestsStepResults])
const moderationOpts = useModerationOpts()
const {
isLoading: isProfilesLoading,
isError,
data,
error,
} = useProfilesQuery({
handles: suggestedDids,
})
const [dids, setDids] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false)
const interestsText = React.useMemo(() => {
const i = state.interestsStepResults.selectedInterests.map(
i => interestsDisplayNames[i] || capitalize(i),
)
return i.join(', ')
}, [state.interestsStepResults.selectedInterests, interestsDisplayNames])
const handleContinue = React.useCallback(async () => {
setSaving(true)
if (dids.length) {
dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids})
}
setSaving(false)
dispatch({type: 'next'})
track('OnboardingV2:StepSuggestedAccounts:End', {
selectedAccountsLength: dids.length,
})
logEvent('onboarding:suggestedAccounts:nextPressed', {
selectedAccountsLength: dids.length,
skipped: false,
})
}, [dids, setSaving, dispatch, track])
const handleSkip = React.useCallback(() => {
// if a user comes back and clicks skip, erase follows
dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []})
dispatch({type: 'next'})
logEvent('onboarding:suggestedAccounts:nextPressed', {
selectedAccountsLength: 0,
skipped: true,
})
}, [dispatch])
const isLoading = isProfilesLoading && moderationOpts
React.useEffect(() => {
track('OnboardingV2:StepSuggestedAccounts:Start')
}, [track])
return (
<View style={[a.align_start]}>
<IconCircle icon={At} style={[a.mb_2xl]} />
<TitleText>
<Trans>Here are some accounts for you to follow</Trans>
</TitleText>
<DescriptionText>
{state.interestsStepResults.selectedInterests.length ? (
<Trans>Based on your interest in {interestsText}</Trans>
) : (
<Trans>These are popular accounts you might like:</Trans>
)}
</DescriptionText>
<View style={[a.w_full, a.pt_xl]}>
{isLoading ? (
<View style={[a.gap_md]}>
{Array(10)
.fill(0)
.map((_, i) => (
<SuggestedAccountCardPlaceholder key={i} />
))}
</View>
) : isError || !data ? (
<Text>{error?.toString()}</Text>
) : (
<Inner
profiles={data.profiles}
onSelect={setDids}
moderationOpts={moderationOpts}
/>
)}
</View>
<OnboardingControls.Portal>
<View
style={[
a.gap_md,
gtMobile ? {flexDirection: 'row-reverse'} : a.flex_col,
]}>
<Button
disabled={dids.length === 0}
variant="gradient"
color="gradient_sky"
size="large"
label={_(
msg`Follow selected accounts and continue to the next step`,
)}
onPress={handleContinue}>
<ButtonText>
{dids.length === 20 ? (
<Trans>Follow All</Trans>
) : (
<Trans>Follow</Trans>
)}
</ButtonText>
<ButtonIcon icon={saving ? Loader : Plus} position="right" />
</Button>
<Button
variant="solid"
color="secondary"
size="large"
label={_(
msg`Continue to the next step without following any accounts`,
)}
onPress={handleSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</View>
</OnboardingControls.Portal>
</View>
)
}

View File

@ -1,125 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {IS_TEST_USER} from 'lib/constants'
import {useSession} from 'state/session'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
import {Loader} from '#/components/Loader'
export function StepTopicalFeeds() {
const {_} = useLingui()
const {track} = useAnalytics()
const {currentAccount} = useSession()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false)
const suggestedFeedUris = React.useMemo(() => {
if (IS_TEST_USER(currentAccount?.handle)) return []
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
).slice(0, 10)
}, [
currentAccount?.handle,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.selectedInterests,
])
const interestsText = React.useMemo(() => {
const i = state.interestsStepResults.selectedInterests.map(
i => interestsDisplayNames[i] || capitalize(i),
)
return i.join(', ')
}, [state.interestsStepResults.selectedInterests, interestsDisplayNames])
const saveFeeds = React.useCallback(async () => {
setSaving(true)
dispatch({type: 'setTopicalFeedsStepResults', feedUris: selectedFeedUris})
setSaving(false)
dispatch({type: 'next'})
track('OnboardingV2:StepTopicalFeeds:End', {
selectedFeeds: selectedFeedUris,
selectedFeedsLength: selectedFeedUris.length,
})
logEvent('onboarding:topicalFeeds:nextPressed', {
selectedFeeds: selectedFeedUris,
selectedFeedsLength: selectedFeedUris.length,
})
}, [selectedFeedUris, dispatch, track])
React.useEffect(() => {
track('OnboardingV2:StepTopicalFeeds:Start')
}, [track])
return (
<View style={[a.align_start]}>
<IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
<TitleText>
<Trans>Feeds can be topical as well!</Trans>
</TitleText>
<DescriptionText>
{state.interestsStepResults.selectedInterests.length ? (
<Trans>
Here are some topical feeds based on your interests: {interestsText}
. You can choose to follow as many as you like.
</Trans>
) : (
<Trans>
Here are some popular topical feeds. You can choose to follow as
many as you like.
</Trans>
)}
</DescriptionText>
<View style={[a.w_full, a.pb_2xl, a.pt_2xl]}>
<Toggle.Group
values={selectedFeedUris}
onChange={setSelectedFeedUris}
label={_(msg`Select topical feeds to follow from the list below`)}>
<View style={[a.gap_md]}>
{suggestedFeedUris.map(uri => (
<FeedCard key={uri} config={{default: false, uri}} />
))}
</View>
</Toggle.Group>
</View>
<OnboardingControls.Portal>
<Button
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to next step`)}
onPress={saveFeeds}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
</Button>
</OnboardingControls.Portal>
</View>
)
}

View File

@ -2,33 +2,18 @@ import React from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
import { import {Context, initialState, reducer} from '#/screens/Onboarding/state'
Context,
initialState,
initialStateReduced,
reducer,
reducerReduced,
} from '#/screens/Onboarding/state'
import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepModeration} from '#/screens/Onboarding/StepModeration'
import {StepProfile} from '#/screens/Onboarding/StepProfile' import {StepProfile} from '#/screens/Onboarding/StepProfile'
import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
export function Onboarding() { export function Onboarding() {
const {_} = useLingui() const {_} = useLingui()
const gate = useGate() const [state, dispatch] = React.useReducer(reducer, {
const isReducedOnboardingEnabled = gate('reduced_onboarding_and_home_algo_v2') ...initialState,
const [state, dispatch] = React.useReducer( })
isReducedOnboardingEnabled ? reducerReduced : reducer,
isReducedOnboardingEnabled ? {...initialStateReduced} : {...initialState},
)
const interestsDisplayNames = React.useMemo(() => { const interestsDisplayNames = React.useMemo(() => {
return { return {
@ -68,13 +53,6 @@ export function Onboarding() {
<Layout> <Layout>
{state.activeStep === 'profile' && <StepProfile />} {state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />} {state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggestedAccounts' && (
<StepSuggestedAccounts />
)}
{state.activeStep === 'followingFeed' && <StepFollowingFeed />}
{state.activeStep === 'algoFeeds' && <StepAlgoFeeds />}
{state.activeStep === 'topicalFeeds' && <StepTopicalFeeds />}
{state.activeStep === 'moderation' && <StepModeration />}
{state.activeStep === 'finished' && <StepFinished />} {state.activeStep === 'finished' && <StepFinished />}
</Layout> </Layout>
</Context.Provider> </Context.Provider>

View File

@ -6,31 +6,13 @@ import {AvatarColor, Emoji} from '#/screens/Onboarding/StepProfile/types'
export type OnboardingState = { export type OnboardingState = {
hasPrev: boolean hasPrev: boolean
totalSteps: number totalSteps: number
activeStep: activeStep: 'profile' | 'interests' | 'finished'
| 'profile'
| 'interests'
| 'suggestedAccounts'
| 'followingFeed'
| 'algoFeeds'
| 'topicalFeeds'
| 'moderation'
| 'profile'
| 'finished'
activeStepIndex: number activeStepIndex: number
interestsStepResults: { interestsStepResults: {
selectedInterests: string[] selectedInterests: string[]
apiResponse: ApiResponseMap apiResponse: ApiResponseMap
} }
suggestedAccountsStepResults: {
accountDids: string[]
}
algoFeedsStepResults: {
feedUris: string[]
}
topicalFeedsStepResults: {
feedUris: string[]
}
profileStepResults: { profileStepResults: {
isCreatedAvatar: boolean isCreatedAvatar: boolean
image?: { image?: {
@ -64,18 +46,6 @@ export type OnboardingAction =
selectedInterests: string[] selectedInterests: string[]
apiResponse: ApiResponseMap apiResponse: ApiResponseMap
} }
| {
type: 'setSuggestedAccountsStepResults'
accountDids: string[]
}
| {
type: 'setAlgoFeedsStepResults'
feedUris: string[]
}
| {
type: 'setTopicalFeedsStepResults'
feedUris: string[]
}
| { | {
type: 'setProfileStepResults' type: 'setProfileStepResults'
isCreatedAvatar: boolean isCreatedAvatar: boolean
@ -98,37 +68,6 @@ export type ApiResponseMap = {
} }
} }
export const initialState: OnboardingState = {
hasPrev: false,
totalSteps: 7,
activeStep: 'interests',
activeStepIndex: 1,
interestsStepResults: {
selectedInterests: [],
apiResponse: {
interests: [],
suggestedAccountDids: {},
suggestedFeedUris: {},
},
},
suggestedAccountsStepResults: {
accountDids: [],
},
algoFeedsStepResults: {
feedUris: [],
},
topicalFeedsStepResults: {
feedUris: [],
},
profileStepResults: {
isCreatedAvatar: false,
image: undefined,
imageUri: '',
imageMime: '',
},
}
export const INTEREST_TO_DISPLAY_NAME_DEFAULTS: { export const INTEREST_TO_DISPLAY_NAME_DEFAULTS: {
[key: string]: string [key: string]: string
} = { } = {
@ -156,125 +95,7 @@ export const INTEREST_TO_DISPLAY_NAME_DEFAULTS: {
cooking: 'Cooking', cooking: 'Cooking',
} }
export const Context = React.createContext<{ export const initialState: OnboardingState = {
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
interestsDisplayNames: {[key: string]: string}
}>({
state: {...initialState},
dispatch: () => {},
interestsDisplayNames: INTEREST_TO_DISPLAY_NAME_DEFAULTS,
})
export function reducer(
s: OnboardingState,
a: OnboardingAction,
): OnboardingState {
let next = {...s}
switch (a.type) {
case 'next': {
if (s.activeStep === 'interests') {
next.activeStep = 'suggestedAccounts'
next.activeStepIndex = 2
} else if (s.activeStep === 'suggestedAccounts') {
next.activeStep = 'followingFeed'
next.activeStepIndex = 3
} else if (s.activeStep === 'followingFeed') {
next.activeStep = 'algoFeeds'
next.activeStepIndex = 4
} else if (s.activeStep === 'algoFeeds') {
next.activeStep = 'topicalFeeds'
next.activeStepIndex = 5
} else if (s.activeStep === 'topicalFeeds') {
next.activeStep = 'moderation'
next.activeStepIndex = 6
} else if (s.activeStep === 'moderation') {
next.activeStep = 'finished'
next.activeStepIndex = 7
}
break
}
case 'prev': {
if (s.activeStep === 'suggestedAccounts') {
next.activeStep = 'interests'
next.activeStepIndex = 1
} else if (s.activeStep === 'followingFeed') {
next.activeStep = 'suggestedAccounts'
next.activeStepIndex = 2
} else if (s.activeStep === 'algoFeeds') {
next.activeStep = 'followingFeed'
next.activeStepIndex = 3
} else if (s.activeStep === 'topicalFeeds') {
next.activeStep = 'algoFeeds'
next.activeStepIndex = 4
} else if (s.activeStep === 'moderation') {
next.activeStep = 'topicalFeeds'
next.activeStepIndex = 5
} else if (s.activeStep === 'finished') {
next.activeStep = 'moderation'
next.activeStepIndex = 6
}
break
}
case 'finish': {
next = initialState
break
}
case 'setInterestsStepResults': {
next.interestsStepResults = {
selectedInterests: a.selectedInterests,
apiResponse: a.apiResponse,
}
break
}
case 'setSuggestedAccountsStepResults': {
next.suggestedAccountsStepResults = {
accountDids: next.suggestedAccountsStepResults.accountDids.concat(
a.accountDids,
),
}
break
}
case 'setAlgoFeedsStepResults': {
next.algoFeedsStepResults = {
feedUris: a.feedUris,
}
break
}
case 'setTopicalFeedsStepResults': {
next.topicalFeedsStepResults = {
feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris),
}
break
}
}
const state = {
...next,
hasPrev: next.activeStep !== 'interests',
}
logger.debug(`onboarding`, {
hasPrev: state.hasPrev,
activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests,
},
suggestedAccountsStepResults: state.suggestedAccountsStepResults,
algoFeedsStepResults: state.algoFeedsStepResults,
topicalFeedsStepResults: state.topicalFeedsStepResults,
})
if (s.activeStep !== state.activeStep) {
logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
}
return state
}
export const initialStateReduced: OnboardingState = {
hasPrev: false, hasPrev: false,
totalSteps: 3, totalSteps: 3,
activeStep: 'profile', activeStep: 'profile',
@ -288,15 +109,6 @@ export const initialStateReduced: OnboardingState = {
suggestedFeedUris: {}, suggestedFeedUris: {},
}, },
}, },
suggestedAccountsStepResults: {
accountDids: [],
},
algoFeedsStepResults: {
feedUris: [],
},
topicalFeedsStepResults: {
feedUris: [],
},
profileStepResults: { profileStepResults: {
isCreatedAvatar: false, isCreatedAvatar: false,
image: undefined, image: undefined,
@ -305,7 +117,17 @@ export const initialStateReduced: OnboardingState = {
}, },
} }
export function reducerReduced( export const Context = React.createContext<{
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
interestsDisplayNames: {[key: string]: string}
}>({
state: {...initialState},
dispatch: () => {},
interestsDisplayNames: INTEREST_TO_DISPLAY_NAME_DEFAULTS,
})
export function reducer(
s: OnboardingState, s: OnboardingState,
a: OnboardingAction, a: OnboardingAction,
): OnboardingState { ): OnboardingState {
@ -333,7 +155,7 @@ export function reducerReduced(
break break
} }
case 'finish': { case 'finish': {
next = initialStateReduced next = initialState
break break
} }
case 'setInterestsStepResults': { case 'setInterestsStepResults': {
@ -343,15 +165,6 @@ export function reducerReduced(
} }
break break
} }
case 'setSuggestedAccountsStepResults': {
break
}
case 'setAlgoFeedsStepResults': {
break
}
case 'setTopicalFeedsStepResults': {
break
}
case 'setProfileStepResults': { case 'setProfileStepResults': {
next.profileStepResults = { next.profileStepResults = {
isCreatedAvatar: a.isCreatedAvatar, isCreatedAvatar: a.isCreatedAvatar,
@ -376,9 +189,6 @@ export function reducerReduced(
interestsStepResults: { interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests, selectedInterests: state.interestsStepResults.selectedInterests,
}, },
suggestedAccountsStepResults: state.suggestedAccountsStepResults,
algoFeedsStepResults: state.algoFeedsStepResults,
topicalFeedsStepResults: state.topicalFeedsStepResults,
profileStepResults: state.profileStepResults, profileStepResults: state.profileStepResults,
}) })

View File

@ -5,66 +5,6 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {PRIMARY_FEEDS} from './StepAlgoFeeds'
function shuffle(array: any) {
let currentIndex = array.length,
randomIndex
// While there remain elements to shuffle.
while (currentIndex > 0) {
// Pick a remaining element.
randomIndex = Math.floor(Math.random() * currentIndex)
currentIndex--
// And swap it with the current element.
;[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex],
]
}
return array
}
export function aggregateInterestItems(
interests: string[],
map: {[key: string]: string[]},
fallbackItems: string[],
) {
const selected = interests.length
const all = interests
.map(i => {
// suggestions from server
const rawSuggestions = map[i]
// safeguard against a missing interest->suggestion mapping
if (!rawSuggestions || !rawSuggestions.length) {
return []
}
const suggestions = shuffle(rawSuggestions)
if (selected === 1) {
return suggestions // return all
} else if (selected === 2) {
return suggestions.slice(0, 5) // return 5
} else {
return suggestions.slice(0, 3) // return 3
}
})
.flat()
// dedupe suggestions
const results = Array.from(new Set(all))
// backfill
if (results.length < 20) {
results.push(...shuffle(fallbackItems))
}
// dedupe and return 20
return Array.from(new Set(results)).slice(0, 20)
}
export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) { export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
const session = agent.session const session = agent.session
@ -109,19 +49,3 @@ async function whenFollowsIndexed(
}), }),
) )
} }
/**
* Kinda hacky, but we want Discover to appear as the first pinned
* feed after Following
*/
export function sortPrimaryAlgorithmFeeds(uris: string[]) {
return uris.sort((a, b) => {
if (a === PRIMARY_FEEDS[0]?.uri) {
return -1
}
if (b === PRIMARY_FEEDS[0]?.uri) {
return 1
}
return a.localeCompare(b)
})
}

View File

@ -2,7 +2,6 @@ import React from 'react'
import {LogBox, Pressable, View} from 'react-native' import {LogBox, Pressable, View} from 'react-native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useDangerousSetGate} from '#/lib/statsig/statsig'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@ -25,7 +24,6 @@ export function TestCtrls() {
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const setGate = useDangerousSetGate()
const onPressSignInAlice = async () => { const onPressSignInAlice = async () => {
await login( await login(
{ {
@ -117,8 +115,6 @@ export function TestCtrls() {
<Pressable <Pressable
testID="e2eStartOnboarding" testID="e2eStartOnboarding"
onPress={() => { onPress={() => {
// TODO remove when experiment is over
setGate('reduced_onboarding_and_home_algo_v2', true)
onboardingDispatch({type: 'start'}) onboardingDispatch({type: 'start'})
}} }}
accessibilityRole="button" accessibilityRole="button"
@ -128,8 +124,6 @@ export function TestCtrls() {
<Pressable <Pressable
testID="e2eStartLongboarding" testID="e2eStartLongboarding"
onPress={() => { onPress={() => {
// TODO remove when experiment is over
setGate('reduced_onboarding_and_home_algo_v2', false)
onboardingDispatch({type: 'start'}) onboardingDispatch({type: 'start'})
}} }}
accessibilityRole="button" accessibilityRole="button"