diff options
Diffstat (limited to 'src/screens')
18 files changed, 2674 insertions, 0 deletions
diff --git a/src/screens/Onboarding/IconCircle.tsx b/src/screens/Onboarding/IconCircle.tsx new file mode 100644 index 000000000..a54c8b4e4 --- /dev/null +++ b/src/screens/Onboarding/IconCircle.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx new file mode 100644 index 000000000..50487c189 --- /dev/null +++ b/src/screens/Onboarding/Layout.tsx @@ -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> +} diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx new file mode 100644 index 000000000..c7f1e6e4d --- /dev/null +++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx new file mode 100644 index 000000000..4920c5ad7 --- /dev/null +++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx new file mode 100644 index 000000000..02c45f590 --- /dev/null +++ b/src/screens/Onboarding/StepFinished.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx new file mode 100644 index 000000000..4b3c62889 --- /dev/null +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepInterests/InterestButton.tsx b/src/screens/Onboarding/StepInterests/InterestButton.tsx new file mode 100644 index 000000000..02413b18d --- /dev/null +++ b/src/screens/Onboarding/StepInterests/InterestButton.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepInterests/data.ts b/src/screens/Onboarding/StepInterests/data.ts new file mode 100644 index 000000000..00a25331c --- /dev/null +++ b/src/screens/Onboarding/StepInterests/data.ts @@ -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[] + } +} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx new file mode 100644 index 000000000..6f60991d5 --- /dev/null +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx new file mode 100644 index 000000000..bc4c0387f --- /dev/null +++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx @@ -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 + } +} diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx new file mode 100644 index 000000000..904c47299 --- /dev/null +++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx new file mode 100644 index 000000000..be605e407 --- /dev/null +++ b/src/screens/Onboarding/StepModeration/index.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx new file mode 100644 index 000000000..bc707ce8f --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 000000000..723e53a98 --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx new file mode 100644 index 000000000..516c18e6e --- /dev/null +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx new file mode 100644 index 000000000..a4eb04012 --- /dev/null +++ b/src/screens/Onboarding/index.tsx @@ -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> + ) +} diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts new file mode 100644 index 000000000..164c2f5f3 --- /dev/null +++ b/src/screens/Onboarding/state.ts @@ -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 +} diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts new file mode 100644 index 000000000..2a709a67b --- /dev/null +++ b/src/screens/Onboarding/util.ts @@ -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 + }) +} |