New Onboarding (#2596)
* Add round and square buttons * Allow some style for buttons, add icons * Change text selection color * Center button text, whoops * Outer layout, some primitive updates * WIP * onboarding feed prefs (#2590) * add `style` to toggle label to modify text style * Revert "add `style` to toggle label to modify text style" This reverts commit 8f4b517b8585ca64a4bf44f6cb40ac070ece8932. * following feed prefs * remove unnecessary memo * reusable divider component * org imports * add finished screen * Theme SelectedAccountCard * Require at least 3 interests * Placeholder save logic * WIP algo feeds * Improve lineHeight handling, add RichText, improve Link by adding InlineLink * Inherit lineHeight in heading comps * Algo feeds mostly good * Topical feeds ish * Layout cleanup * Improve button styles * moderation prefs for onboarding (#2594) * WIP algo feeds * modify controlalbelgroup typing for easy .map() * adjust padding on button * add moderation screen * add moderation screen * add moderation screen --------- Co-authored-by: Eric Bailey <git@esb.lol> * Fix toggle button styles * A11y props on outer portal * Put it all on red * New data shape * Handle mock data * Bulk write (not yet) * Remove interests validation * Clean up interests * i18n layout and first step * Clean up suggested follows screen * Clean up following step * Clean up algo feeds step * Clean up topical feeds * Add skeleton for feed card * WIP moderation step * cleanup moderation styles (#2605) * cleanup moderation styles * fix(?) toggle button group styles * adjust toggle to fit any screen * Some more cleanup * Icons * ToggleButton tweaks * Reset * Hook up data * Better suggestions * Bulk write * Some logging * Use new api * Concat topical feeds * Metrics * Disable links in RichText, feedcards * Tweak primary feed cards * Update metrics * Fix layout shift * Fix ToggleButton again, whoops * Error state * Bump api package, ensure interests are saved * Better fix for autofill * i18n, button positions * Remove unused export * Add default prefs object * Fix overflow in user cards * Add 2 lines of bios to suggested accounts cards * Nits * Don't resolve facets by default * Update storybook * Disable flag for now * Remove age dialog from moderations step * Improvements and tweaks to new onboarding --------- Co-authored-by: Hailey <153161762+haileyok@users.noreply.github.com> Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
parent
5443503593
commit
3371038f7d
75 changed files with 3514 additions and 210 deletions
51
src/screens/Onboarding/IconCircle.tsx
Normal file
51
src/screens/Onboarding/IconCircle.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {
|
||||
useTheme,
|
||||
atoms as a,
|
||||
ViewStyleProp,
|
||||
TextStyleProp,
|
||||
flatten,
|
||||
} from '#/alf'
|
||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
||||
import {Props} from '#/components/icons/common'
|
||||
|
||||
export function IconCircle({
|
||||
icon: Icon,
|
||||
size = 'xl',
|
||||
style,
|
||||
iconStyle,
|
||||
}: ViewStyleProp & {
|
||||
icon: typeof Growth
|
||||
size?: Props['size']
|
||||
iconStyle?: TextStyleProp['style']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
<Icon
|
||||
size={size}
|
||||
style={[
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
flatten(iconStyle),
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
231
src/screens/Onboarding/Layout.tsx
Normal file
231
src/screens/Onboarding/Layout.tsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {IS_DEV} from '#/env'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
|
||||
import {
|
||||
useTheme,
|
||||
atoms as a,
|
||||
useBreakpoints,
|
||||
web,
|
||||
native,
|
||||
flatten,
|
||||
TextStyleProp,
|
||||
} from '#/alf'
|
||||
import {H2, P, leading} from '#/components/Typography'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
|
||||
const COL_WIDTH = 500
|
||||
|
||||
export const OnboardingControls = createPortalGroup()
|
||||
|
||||
export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const scrollview = React.useRef<ScrollView>(null)
|
||||
const prevActiveStep = React.useRef<string>(state.activeStep)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.activeStep !== prevActiveStep.current) {
|
||||
prevActiveStep.current = state.activeStep
|
||||
scrollview.current?.scrollTo({y: 0, animated: false})
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const paddingTop = gtMobile ? a.py_5xl : a.py_lg
|
||||
const dialogLabel = _(msg`Set up your account`)
|
||||
|
||||
return (
|
||||
<View
|
||||
aria-modal
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={dialogLabel}
|
||||
accessibilityLabel={dialogLabel}
|
||||
accessibilityHint={_(
|
||||
msg`The following steps will help customize your Bluesky experience.`,
|
||||
)}
|
||||
style={[
|
||||
// @ts-ignore web only -prf
|
||||
isWeb ? a.fixed : a.absolute,
|
||||
a.inset_0,
|
||||
a.flex_1,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
{IS_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">
|
||||
Clear
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!gtMobile && state.hasPrev && (
|
||||
<View
|
||||
style={[
|
||||
web(a.fixed),
|
||||
native(a.absolute),
|
||||
a.flex_row,
|
||||
a.w_full,
|
||||
a.justify_center,
|
||||
a.z_20,
|
||||
a.px_xl,
|
||||
{
|
||||
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>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollview}
|
||||
style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
|
||||
contentContainerStyle={{borderWidth: 0}}
|
||||
// @ts-ignore 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.w_full, a.align_center, paddingTop]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.w_full,
|
||||
{paddingTop: 17, maxWidth: '60%'},
|
||||
]}>
|
||||
{Array(state.totalSteps)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.pt_xs,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
backgroundColor:
|
||||
i + 1 <= state.activeStepIndex
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_100,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
style={[
|
||||
// @ts-ignore web only -prf
|
||||
isWeb ? a.fixed : a.absolute,
|
||||
{bottom: 0, left: 0, right: 0},
|
||||
t.atoms.bg,
|
||||
t.atoms.border,
|
||||
a.border_t,
|
||||
a.align_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
isWeb
|
||||
? a.py_2xl
|
||||
: {
|
||||
paddingTop: a.pt_lg.paddingTop,
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
{maxWidth: COL_WIDTH},
|
||||
gtMobile && [a.flex_row, a.justify_between],
|
||||
]}>
|
||||
{gtMobile &&
|
||||
(state.hasPrev ? (
|
||||
<Button
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={() => dispatch({type: 'prev'})}>
|
||||
<ButtonIcon icon={ChevronLeft} />
|
||||
</Button>
|
||||
) : (
|
||||
<View style={{height: 54}} />
|
||||
))}
|
||||
<OnboardingControls.Outlet />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Title({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
return (
|
||||
<H2
|
||||
style={[
|
||||
a.pb_sm,
|
||||
{
|
||||
lineHeight: leading(a.text_4xl, a.leading_tight),
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</H2>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
const t = useTheme()
|
||||
return <P style={[t.atoms.text_contrast_700, flatten(style)]}>{children}</P>
|
||||
}
|
378
src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
Normal file
378
src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
Normal file
|
@ -0,0 +1,378 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Image} from 'expo-image'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {Text, H3} from '#/components/Typography'
|
||||
import {RichText} from '#/components/RichText'
|
||||
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds'
|
||||
|
||||
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]}>
|
||||
<H3
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
ctx.selected && styles.textSelected,
|
||||
]}>
|
||||
{feed.displayName}
|
||||
</H3>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
{opacity: 0.6},
|
||||
a.text_md,
|
||||
a.py_xs,
|
||||
ctx.selected && styles.textSelected,
|
||||
]}>
|
||||
by @{feed.creatorHandle}
|
||||
</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.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
|
||||
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_grow]}>
|
||||
<H3
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
ctx.selected && styles.textSelected,
|
||||
]}>
|
||||
{feed.displayName}
|
||||
</H3>
|
||||
<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.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
|
||||
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,
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.pt_md, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
{width: '60%', height: 12},
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_100,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
160
src/screens/Onboarding/StepAlgoFeeds/index.tsx
Normal file
160
src/screens/Onboarding/StepAlgoFeeds/index.tsx
Normal file
|
@ -0,0 +1,160 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
export type FeedConfig = {
|
||||
default: boolean
|
||||
uri: string
|
||||
gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic
|
||||
}
|
||||
|
||||
const PRIMARY_FEEDS: FeedConfig[] = [
|
||||
{
|
||||
default: true,
|
||||
uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm',
|
||||
gradient: tokens.gradients.midnight,
|
||||
},
|
||||
{
|
||||
default: false,
|
||||
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,
|
||||
})
|
||||
}, [primaryFeedUris, secondaryFeedUris, dispatch, track])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepAlgoFeeds:Start')
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
|
||||
|
||||
<Title>
|
||||
<Trans>Choose your algorithmic feeds</Trans>
|
||||
</Title>
|
||||
<Description>
|
||||
<Trans>
|
||||
Feeds are created by users and can give you entirely new experiences.
|
||||
</Trans>
|
||||
</Description>
|
||||
|
||||
<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_700]}>
|
||||
<Trans>We recommend "For You" by Skygaze:</Trans>
|
||||
</Text>
|
||||
<FeedCard config={PRIMARY_FEEDS[0]} />
|
||||
<Text
|
||||
style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}>
|
||||
<Trans>Or you can try our "Discover" algorithm:</Trans>
|
||||
</Text>
|
||||
<FeedCard config={PRIMARY_FEEDS[1]} />
|
||||
</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_700]}>
|
||||
<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>
|
||||
)
|
||||
}
|
158
src/screens/Onboarding/StepFinished.tsx
Normal file
158
src/screens/Onboarding/StepFinished.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {
|
||||
bulkWriteFollows,
|
||||
sortPrimaryAlgorithmFeeds,
|
||||
} from '#/screens/Onboarding/util'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
setSaving(true)
|
||||
|
||||
const {
|
||||
interestsStepResults,
|
||||
suggestedAccountsStepResults,
|
||||
algoFeedsStepResults,
|
||||
topicalFeedsStepResults,
|
||||
} = state
|
||||
const {selectedInterests} = interestsStepResults
|
||||
const selectedFeeds = [
|
||||
...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris),
|
||||
...topicalFeedsStepResults.feedUris,
|
||||
]
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
bulkWriteFollows(suggestedAccountsStepResults.accountDids),
|
||||
// these must be serial
|
||||
(async () => {
|
||||
await getAgent().setInterestsPref({tags: selectedInterests})
|
||||
await saveFeeds({
|
||||
saved: selectedFeeds,
|
||||
pinned: selectedFeeds,
|
||||
})
|
||||
})(),
|
||||
])
|
||||
} catch (e: any) {
|
||||
logger.info(`onboarding: bulk save failed`)
|
||||
logger.error(e)
|
||||
// don't alert the user, just let them into their account
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
track('OnboardingV2:StepFinished:End')
|
||||
track('OnboardingV2:Complete')
|
||||
}, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepFinished:Start')
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={Check} style={[a.mb_2xl]} />
|
||||
|
||||
<Title>
|
||||
<Trans>You're ready to go!</Trans>
|
||||
</Title>
|
||||
<Description>
|
||||
<Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
|
||||
</Description>
|
||||
|
||||
<View style={[a.pt_5xl, a.gap_3xl]}>
|
||||
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
|
||||
<IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_lg]}>
|
||||
<Trans>Public</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Your posts, likes, and blocks are public. Mutes are private.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
|
||||
<IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_lg]}>
|
||||
<Trans>Open</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
|
||||
<Trans>Never lose access to your followers and data.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
|
||||
<IconCircle
|
||||
icon={Trending}
|
||||
size="lg"
|
||||
style={{width: 48, height: 48}}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_lg]}>
|
||||
<Trans>Flexible</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
|
||||
<Trans>Choose the algorithms that power your custom feeds.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
<Button
|
||||
disabled={saving}
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
variant="gradient"
|
||||
color="gradient_sky"
|
||||
size="large"
|
||||
label={_(msg`Complete onboarding and start using your account`)}
|
||||
onPress={finishOnboarding}>
|
||||
<ButtonText>
|
||||
{saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
|
||||
</ButtonText>
|
||||
{saving && <ButtonIcon icon={Loader} position="right" />}
|
||||
</Button>
|
||||
</OnboardingControls.Portal>
|
||||
</View>
|
||||
)
|
||||
}
|
160
src/screens/Onboarding/StepFollowingFeed.tsx
Normal file
160
src/screens/Onboarding/StepFollowingFeed.tsx
Normal file
|
@ -0,0 +1,160 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useSetFeedViewPreferencesMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
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')
|
||||
}, [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]} />
|
||||
|
||||
<Title>
|
||||
<Trans>Your default feed is "Following"</Trans>
|
||||
</Title>
|
||||
<Description style={[a.mb_md]}>
|
||||
<Trans>It show posts from the people your follow as they happen.</Trans>
|
||||
</Description>
|
||||
|
||||
<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>
|
||||
|
||||
<Description style={[a.mt_lg]}>
|
||||
<Trans>You can change these settings later.</Trans>
|
||||
</Description>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
79
src/screens/Onboarding/StepInterests/InterestButton.tsx
Normal file
79
src/screens/Onboarding/StepInterests/InterestButton.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React from 'react'
|
||||
import {View, ViewStyle, TextStyle} from 'react-native'
|
||||
|
||||
import {useTheme, atoms as a, native} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
|
||||
|
||||
export function InterestButton({interest}: {interest: string}) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
const hovered: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.contrast_200 : t.palette.contrast_50,
|
||||
},
|
||||
]
|
||||
const focused: ViewStyle[] = []
|
||||
const pressed: ViewStyle[] = []
|
||||
const selected: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
},
|
||||
]
|
||||
const selectedHover: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
},
|
||||
]
|
||||
const textSelected: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.contrast_100,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
hovered,
|
||||
focused,
|
||||
pressed,
|
||||
selected,
|
||||
selectedHover,
|
||||
textSelected,
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
a.rounded_full,
|
||||
a.px_2xl,
|
||||
ctx.hovered ? styles.hovered : {},
|
||||
ctx.focused ? styles.hovered : {},
|
||||
ctx.pressed ? styles.hovered : {},
|
||||
ctx.selected ? styles.selected : {},
|
||||
ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed)
|
||||
? styles.selectedHover
|
||||
: {},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: t.palette.contrast_900,
|
||||
},
|
||||
a.font_bold,
|
||||
native({paddingTop: 2}),
|
||||
ctx.selected ? styles.textSelected : {},
|
||||
]}>
|
||||
{INTEREST_TO_DISPLAY_NAME[interest]}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
36
src/screens/Onboarding/StepInterests/data.ts
Normal file
36
src/screens/Onboarding/StepInterests/data.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
export const INTEREST_TO_DISPLAY_NAME: {
|
||||
[key: string]: string
|
||||
} = {
|
||||
news: 'News',
|
||||
journalism: 'Journalism',
|
||||
nature: 'Nature',
|
||||
art: 'Art',
|
||||
comics: 'Comics',
|
||||
writers: 'Writers',
|
||||
culture: 'Culture',
|
||||
sports: 'Sports',
|
||||
pets: 'Pets',
|
||||
animals: 'Animals',
|
||||
books: 'Books',
|
||||
education: 'Education',
|
||||
climate: 'Climate',
|
||||
science: 'Science',
|
||||
politics: 'Politics',
|
||||
fitness: 'Fitness',
|
||||
tech: 'Tech',
|
||||
dev: 'Software Dev',
|
||||
comedy: 'Comedy',
|
||||
gaming: 'Video Games',
|
||||
food: 'Food',
|
||||
cooking: 'Cooking',
|
||||
}
|
||||
|
||||
export type ApiResponseMap = {
|
||||
interests: string[]
|
||||
suggestedAccountDids: {
|
||||
[key: string]: string[]
|
||||
}
|
||||
suggestedFeedUris: {
|
||||
[key: string]: string[]
|
||||
}
|
||||
}
|
260
src/screens/Onboarding/StepInterests/index.tsx
Normal file
260
src/screens/Onboarding/StepInterests/index.tsx
Normal file
|
@ -0,0 +1,260 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {
|
||||
ApiResponseMap,
|
||||
INTEREST_TO_DISPLAY_NAME,
|
||||
} from '#/screens/Onboarding/StepInterests/data'
|
||||
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
export function StepInterests() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [interests, setInterests] = React.useState<string[]>(
|
||||
state.interestsStepResults.selectedInterests.map(i => i),
|
||||
)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const {isLoading, isError, error, data, refetch, isFetching} = useQuery({
|
||||
queryKey: ['interests'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const {data} =
|
||||
await getAgent().app.bsky.unspecced.getTaggedSuggestions()
|
||||
return data.suggestions.reduce(
|
||||
(agg, s) => {
|
||||
const {tag, subject, subjectType} = s
|
||||
const isDefault = tag === 'default'
|
||||
|
||||
if (!agg.interests.includes(tag) && !isDefault) {
|
||||
agg.interests.push(tag)
|
||||
}
|
||||
|
||||
if (subjectType === 'user') {
|
||||
agg.suggestedAccountDids[tag] =
|
||||
agg.suggestedAccountDids[tag] || []
|
||||
agg.suggestedAccountDids[tag].push(subject)
|
||||
}
|
||||
|
||||
if (subjectType === 'feed') {
|
||||
// agg all feeds into defaults
|
||||
if (isDefault) {
|
||||
agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
|
||||
} else {
|
||||
agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
|
||||
agg.suggestedFeedUris[tag].push(subject)
|
||||
agg.suggestedFeedUris.default.push(subject)
|
||||
}
|
||||
}
|
||||
|
||||
return agg
|
||||
},
|
||||
{
|
||||
interests: [],
|
||||
suggestedAccountDids: {},
|
||||
suggestedFeedUris: {},
|
||||
} as ApiResponseMap,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.info(
|
||||
`onboarding: getTaggedSuggestions fetch or processing failed`,
|
||||
)
|
||||
logger.error(e)
|
||||
track('OnboardingV2:StepInterests:Error')
|
||||
|
||||
throw new Error(`a network error occurred`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const saveInterests = React.useCallback(async () => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
setSaving(false)
|
||||
dispatch({
|
||||
type: 'setInterestsStepResults',
|
||||
apiResponse: data!,
|
||||
selectedInterests: interests,
|
||||
})
|
||||
dispatch({type: 'next'})
|
||||
|
||||
track('OnboardingV2:StepInterests:End', {
|
||||
selectedInterests: interests,
|
||||
selectedInterestsLength: interests.length,
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.info(`onboading: error saving interests`)
|
||||
logger.error(e)
|
||||
}
|
||||
}, [interests, data, setSaving, dispatch, track])
|
||||
|
||||
const skipOnboarding = React.useCallback(() => {
|
||||
onboardDispatch({type: 'finish'})
|
||||
dispatch({type: 'finish'})
|
||||
track('OnboardingV2:Skip')
|
||||
}, [onboardDispatch, dispatch, track])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:Begin')
|
||||
track('OnboardingV2:StepInterests:Start')
|
||||
}, [track])
|
||||
|
||||
const title = isError ? (
|
||||
<Trans>Oh no! Something went wrong.</Trans>
|
||||
) : (
|
||||
<Trans>What are your interests?</Trans>
|
||||
)
|
||||
const description = isError ? (
|
||||
<Trans>
|
||||
We weren't able to connect. Please try again to continue setting up your
|
||||
account. If it continues to fail, you can skip this flow.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>We'll use this to help customize your experience.</Trans>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle
|
||||
icon={isError ? EmojiSad : Hashtag}
|
||||
style={[
|
||||
a.mb_2xl,
|
||||
isError
|
||||
? {
|
||||
backgroundColor: t.palette.negative_50,
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
iconStyle={[
|
||||
isError
|
||||
? {
|
||||
color: t.palette.negative_900,
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
|
||||
<View style={[a.w_full, a.pt_2xl]}>
|
||||
{isLoading ? (
|
||||
<Loader size="xl" />
|
||||
) : isError || !data ? (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
{
|
||||
backgroundColor: t.palette.negative_50,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_md]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.negative_900,
|
||||
},
|
||||
]}>
|
||||
Error:{' '}
|
||||
</Text>
|
||||
{error?.message || 'an unknown error occurred'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Toggle.Group
|
||||
values={interests}
|
||||
onChange={setInterests}
|
||||
label={_(msg`Select your interests from the options below`)}>
|
||||
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
|
||||
{data.interests.map(interest => (
|
||||
<Toggle.Item
|
||||
key={interest}
|
||||
name={interest}
|
||||
label={INTEREST_TO_DISPLAY_NAME[interest]}>
|
||||
<InterestButton interest={interest} />
|
||||
</Toggle.Item>
|
||||
))}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
{isError ? (
|
||||
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
|
||||
<Button
|
||||
disabled={isFetching}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={() => refetch()}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ArrowRotateCounterClockwise} position="right" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Skip this flow`)}
|
||||
onPress={skipOnboarding}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
disabled={saving || !data}
|
||||
variant="gradient"
|
||||
color="gradient_sky"
|
||||
size="large"
|
||||
label={_(msg`Continue to next step`)}
|
||||
onPress={saveInterests}>
|
||||
<ButtonText>
|
||||
<Trans>Continue</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={saving ? Loader : ChevronRight}
|
||||
position="right"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingControls.Portal>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetAdultContentMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {logger} from '#/logger'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
|
||||
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() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
// Reuse logic here form ContentFilteringSettings.tsx
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetAdultContentMutation()
|
||||
|
||||
const onToggleAdultContent = React.useCallback(async () => {
|
||||
if (isIOS) return
|
||||
|
||||
try {
|
||||
mutate({
|
||||
enabled: !(variables?.enabled ?? preferences?.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, _])
|
||||
|
||||
if (!preferences) return null
|
||||
|
||||
if (isIOS) {
|
||||
if (preferences?.adultContentEnabled === true) {
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<Card>
|
||||
<CircleInfo size="sm" fill={t.palette.contrast_500} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_contrast_700,
|
||||
a.leading_snug,
|
||||
{paddingTop: 1},
|
||||
]}>
|
||||
<Trans>
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<InlineLink style={[a.leading_snug]} to="https://bsky.app">
|
||||
bsky.app
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (preferences?.userAge) {
|
||||
if (preferences.userAge >= 18) {
|
||||
return (
|
||||
<View style={[a.w_full]}>
|
||||
<Toggle.Item
|
||||
name={_(msg`Enable adult content in your feeds`)}
|
||||
label={_(msg`Enable adult content in your feeds`)}
|
||||
value={variables?.enabled ?? preferences?.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]}>Enable Adult Content</Text>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Card>
|
||||
<CircleInfo size="sm" fill={t.palette.contrast_500} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_contrast_700,
|
||||
a.leading_snug,
|
||||
{paddingTop: 1},
|
||||
]}>
|
||||
<Trans>
|
||||
You must be 18 years or older to enable adult content
|
||||
</Trans>
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
85
src/screens/Onboarding/StepModeration/ModerationOption.tsx
Normal file
85
src/screens/Onboarding/StepModeration/ModerationOption.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ConfigurableLabelGroup,
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function ModerationOption({
|
||||
labelGroup,
|
||||
}: {
|
||||
labelGroup: ConfigurableLabelGroup
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const visibility =
|
||||
variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(vis: string[]) => {
|
||||
mutate({labelGroup, visibility: vis[0] as LabelPreference})
|
||||
},
|
||||
[mutate, labelGroup],
|
||||
)
|
||||
|
||||
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, {width: '50%'}]}>
|
||||
<Text style={[a.font_bold]}>{groupInfo.title}</Text>
|
||||
<Text style={[t.atoms.text_contrast_700, a.leading_snug]}>
|
||||
{groupInfo.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
{!preferences?.adultContentEnabled && groupInfo.isAdultImagery ? (
|
||||
<View style={[a.justify_center, {minHeight: 40}]}>
|
||||
<Text style={[a.font_bold]}>{labels.hide}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
|
||||
)}
|
||||
values={[visibility ?? 'hide']}
|
||||
onChange={onChange}>
|
||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||
{labels.hide}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||
{labels.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||
{labels.show}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
91
src/screens/Onboarding/StepModeration/index.tsx
Normal file
91
src/screens/Onboarding/StepModeration/index.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {configurableLabelGroups} from 'state/queries/preferences'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {
|
||||
Description,
|
||||
OnboardingControls,
|
||||
Title,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
|
||||
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
export function StepModeration() {
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
|
||||
const onContinue = React.useCallback(() => {
|
||||
dispatch({type: 'next'})
|
||||
track('OnboardingV2:StepModeration:End')
|
||||
}, [track, dispatch])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepModeration:Start')
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
|
||||
|
||||
<Title>
|
||||
<Trans>You are in control</Trans>
|
||||
</Title>
|
||||
<Description style={[a.mb_xl]}>
|
||||
<Trans>
|
||||
Select the types of content that you want to see (or not see), and
|
||||
we'll handle the rest.
|
||||
</Trans>
|
||||
</Description>
|
||||
|
||||
{!preferences ? (
|
||||
<View style={[a.pt_md]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<AdultContentEnabledPref />
|
||||
|
||||
<View style={[a.gap_sm, a.w_full]}>
|
||||
{configurableLabelGroups.map((g, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index === 0 && <Divider />}
|
||||
<ModerationOption labelGroup={g} />
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
import React from 'react'
|
||||
import {View, ViewStyle} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
|
||||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useItemContext} from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {RichText} from '#/components/RichText'
|
||||
|
||||
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.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_600]}>{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.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
|
||||
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>
|
||||
)
|
||||
}
|
198
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
Normal file
198
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
Normal file
|
@ -0,0 +1,198 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {
|
||||
SuggestedAccountCard,
|
||||
SuggestedAccountCardPlaceholder,
|
||||
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
|
||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
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 {track} = useAnalytics()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
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 => INTEREST_TO_DISPLAY_NAME[i],
|
||||
)
|
||||
return i.join(', ')
|
||||
}, [state.interestsStepResults.selectedInterests])
|
||||
|
||||
const handleContinue = React.useCallback(async () => {
|
||||
setSaving(true)
|
||||
|
||||
if (dids.length) {
|
||||
dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
dispatch({type: 'next'})
|
||||
track('OnboardingV2:StepSuggestedAccounts:Start', {
|
||||
selectedAccountsLength: dids.length,
|
||||
})
|
||||
}, [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'})
|
||||
}, [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]} />
|
||||
|
||||
<Title>
|
||||
<Trans>Here are some accounts for your to follow</Trans>
|
||||
</Title>
|
||||
<Description>
|
||||
{state.interestsStepResults.selectedInterests.length ? (
|
||||
<Trans>Based on your interest in {interestsText}</Trans>
|
||||
) : (
|
||||
<Trans>These are popular accounts you might like.</Trans>
|
||||
)}
|
||||
</Description>
|
||||
|
||||
<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 then next step`,
|
||||
)}
|
||||
onPress={handleContinue}>
|
||||
<ButtonText>
|
||||
<Trans>Follow All</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>
|
||||
)
|
||||
}
|
113
src/screens/Onboarding/StepTopicalFeeds.tsx
Normal file
113
src/screens/Onboarding/StepTopicalFeeds.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Title,
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
|
||||
export function StepTopicalFeeds() {
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const suggestedFeedUris = React.useMemo(() => {
|
||||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
|
||||
).slice(0, 10)
|
||||
}, [state.interestsStepResults])
|
||||
|
||||
const interestsText = React.useMemo(() => {
|
||||
const i = state.interestsStepResults.selectedInterests.map(
|
||||
i => INTEREST_TO_DISPLAY_NAME[i],
|
||||
)
|
||||
return i.join(', ')
|
||||
}, [state.interestsStepResults.selectedInterests])
|
||||
|
||||
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,
|
||||
})
|
||||
}, [selectedFeedUris, dispatch, track])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepTopicalFeeds:Start')
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
|
||||
|
||||
<Title>
|
||||
<Trans>Feeds can be topical as well!</Trans>
|
||||
</Title>
|
||||
<Description>
|
||||
{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>
|
||||
)}
|
||||
</Description>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
38
src/screens/Onboarding/index.tsx
Normal file
38
src/screens/Onboarding/index.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import React from 'react'
|
||||
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
|
||||
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
|
||||
import {StepInterests} from '#/screens/Onboarding/StepInterests'
|
||||
import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
|
||||
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
|
||||
import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
|
||||
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
|
||||
import {StepFinished} from '#/screens/Onboarding/StepFinished'
|
||||
import {StepModeration} from '#/screens/Onboarding/StepModeration'
|
||||
|
||||
export function Onboarding() {
|
||||
const [state, dispatch] = React.useReducer(reducer, {...initialState})
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<OnboardingControls.Provider>
|
||||
<Context.Provider
|
||||
value={React.useMemo(() => ({state, dispatch}), [state, dispatch])}>
|
||||
<Layout>
|
||||
{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 />}
|
||||
</Layout>
|
||||
</Context.Provider>
|
||||
</OnboardingControls.Provider>
|
||||
</Portal>
|
||||
)
|
||||
}
|
201
src/screens/Onboarding/state.ts
Normal file
201
src/screens/Onboarding/state.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
import React from 'react'
|
||||
|
||||
import {ApiResponseMap} from '#/screens/Onboarding/StepInterests/data'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export type OnboardingState = {
|
||||
hasPrev: boolean
|
||||
totalSteps: number
|
||||
activeStep:
|
||||
| 'interests'
|
||||
| 'suggestedAccounts'
|
||||
| 'followingFeed'
|
||||
| 'algoFeeds'
|
||||
| 'topicalFeeds'
|
||||
| 'moderation'
|
||||
| 'finished'
|
||||
activeStepIndex: number
|
||||
|
||||
interestsStepResults: {
|
||||
selectedInterests: string[]
|
||||
apiResponse: ApiResponseMap
|
||||
}
|
||||
suggestedAccountsStepResults: {
|
||||
accountDids: string[]
|
||||
}
|
||||
algoFeedsStepResults: {
|
||||
feedUris: string[]
|
||||
}
|
||||
topicalFeedsStepResults: {
|
||||
feedUris: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type OnboardingAction =
|
||||
| {
|
||||
type: 'next'
|
||||
}
|
||||
| {
|
||||
type: 'prev'
|
||||
}
|
||||
| {
|
||||
type: 'finish'
|
||||
}
|
||||
| {
|
||||
type: 'setInterestsStepResults'
|
||||
selectedInterests: string[]
|
||||
apiResponse: ApiResponseMap
|
||||
}
|
||||
| {
|
||||
type: 'setSuggestedAccountsStepResults'
|
||||
accountDids: string[]
|
||||
}
|
||||
| {
|
||||
type: 'setAlgoFeedsStepResults'
|
||||
feedUris: string[]
|
||||
}
|
||||
| {
|
||||
type: 'setTopicalFeedsStepResults'
|
||||
feedUris: string[]
|
||||
}
|
||||
|
||||
export const initialState: OnboardingState = {
|
||||
hasPrev: false,
|
||||
totalSteps: 7,
|
||||
activeStep: 'interests',
|
||||
activeStepIndex: 1,
|
||||
|
||||
interestsStepResults: {
|
||||
selectedInterests: [],
|
||||
apiResponse: {
|
||||
interests: [],
|
||||
suggestedAccountDids: {},
|
||||
suggestedFeedUris: {},
|
||||
},
|
||||
},
|
||||
suggestedAccountsStepResults: {
|
||||
accountDids: [],
|
||||
},
|
||||
algoFeedsStepResults: {
|
||||
feedUris: [],
|
||||
},
|
||||
topicalFeedsStepResults: {
|
||||
feedUris: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const Context = React.createContext<{
|
||||
state: OnboardingState
|
||||
dispatch: React.Dispatch<OnboardingAction>
|
||||
}>({
|
||||
state: {...initialState},
|
||||
dispatch: () => {},
|
||||
})
|
||||
|
||||
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.info(`onboarding: step changed`, {activeStep: state.activeStep})
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
112
src/screens/Onboarding/util.ts
Normal file
112
src/screens/Onboarding/util.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
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 => {
|
||||
const suggestions = shuffle(map[i])
|
||||
|
||||
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(dids: string[]) {
|
||||
const session = getAgent().session
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`bulkWriteFollows failed: no session`)
|
||||
}
|
||||
|
||||
const followRecords: AppBskyGraphFollow.Record[] = dids.map(did => {
|
||||
return {
|
||||
$type: 'app.bsky.graph.follow',
|
||||
subject: did,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
const followWrites = followRecords.map(r => ({
|
||||
$type: 'com.atproto.repo.applyWrites#create',
|
||||
collection: 'app.bsky.graph.follow',
|
||||
value: r,
|
||||
}))
|
||||
|
||||
await getAgent().com.atproto.repo.applyWrites({
|
||||
repo: session.did,
|
||||
writes: followWrites,
|
||||
})
|
||||
await whenFollowsIndexed(session.did, res => !!res.data.follows.length)
|
||||
}
|
||||
|
||||
async function whenFollowsIndexed(
|
||||
actor: string,
|
||||
fn: (res: AppBskyGraphGetFollows.Response) => boolean,
|
||||
) {
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
fn,
|
||||
() =>
|
||||
getAgent().app.bsky.graph.getFollows({
|
||||
actor,
|
||||
limit: 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinda hacky, but we want For Your or Discover to appear as the first pinned
|
||||
* feed after Following
|
||||
*/
|
||||
export function sortPrimaryAlgorithmFeeds(uris: string[]) {
|
||||
return uris.sort(uri => {
|
||||
return uri.includes('the-algorithm')
|
||||
? -1
|
||||
: uri.includes('whats-hot')
|
||||
? 0
|
||||
: 1
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue