diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-08-27 14:17:45 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-27 04:17:45 -0700 |
commit | eac02901435d7bc79a28e0bff665352b814f9508 (patch) | |
tree | 8d770830a0c7081c5e4ed941b192da34ecb538a8 /src/screens/Onboarding/StepSuggestedAccounts/index.tsx | |
parent | 0617fca5ef1d30e7db49eb7dc8e66f6b586cc207 (diff) | |
download | voidsky-eac02901435d7bc79a28e0bff665352b814f9508.tar.zst |
Add suggested follows experiment to onboarding (#8847)
* add new gated screen to onboarding * add tab bar, adjust layout * replace chevron with arrow * get suggested accounts working on native * tweaks for web * add metrics to follow all * rm non-functional link from card * ensure selected interests are passed through to interests query * fix logcontext * followed all accounts! toast * rm save interests function * Update src/screens/Onboarding/StepSuggestedAccounts/index.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * use admonition * rm comment * Better interest tabs (#8879) * make tabs draggable * move tab component to own file * rm focused state from tab, improve label * add focus styles, remove focus when dragging * rm log * add arrows to tabs * rename Tabs -> InterestTabs * try and simplify approach * rename ref * Update InterestTabs.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/InterestTabs.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/ProgressGuide/FollowDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/ProgressGuide/FollowDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * add newline --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix flex problem * Add value proposition screen experiment (#8898) * add assets * add value prop experiment * add alt text * add metrics * add transitions * add skip button * tweak copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * add borderless variant for web * rm pointer events --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Add slight delay, prevent layout shift * Handle layout shift, add Let's Go! text --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/screens/Onboarding/StepSuggestedAccounts/index.tsx')
-rw-r--r-- | src/screens/Onboarding/StepSuggestedAccounts/index.tsx | 356 |
1 files changed, 356 insertions, 0 deletions
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 000000000..5a9d3464c --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -0,0 +1,356 @@ +import {useCallback, useContext, useMemo, useState} from 'react' +import {View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' +import * as bcp47Match from 'bcp-47-match' + +import {wait} from '#/lib/async/wait' +import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useLanguagePrefs} from '#/state/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent, useSession} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {OnboardingControls} from '#/screens/Onboarding/Layout' +import { + Context, + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import * as toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {bulkWriteFollows} from '../util' + +export function StepSuggestedAccounts() { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const agent = useAgent() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + const {state, dispatch} = useContext(Context) + const onboardDispatch = useOnboardingDispatch() + + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + // keeping track of who was followed via the follow all button + // so we can enable/disable the button without having to dig through the shadow cache + const [followedUsers, setFollowedUsers] = useState<string[]>([]) + + /* + * Special language handling copied wholesale from the Explore screen + */ + const {contentLanguages} = useLanguagePrefs() + const useFullExperience = useMemo(() => { + if (contentLanguages.length === 0) return true + return bcp47Match.basicFilter('en', contentLanguages).length > 0 + }, [contentLanguages]) + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(state.interestsStepResults.selectedInterests)) + const { + data: suggestedUsers, + isLoading, + error, + isRefetching, + refetch, + } = useSuggestedUsers({ + category: selectedInterest || (useFullExperience ? null : interests[0]), + search: !useFullExperience, + overrideInterests: state.interestsStepResults.selectedInterests, + }) + + const isError = !!error + + const skipOnboarding = useCallback(() => { + onboardDispatch({type: 'finish'}) + dispatch({type: 'finish'}) + }, [onboardDispatch, dispatch]) + + const followableDids = + suggestedUsers?.actors + .filter( + user => + user.did !== currentAccount?.did && + !isBlockedOrBlocking(user) && + !isMuted(user) && + !user.viewer?.following && + !followedUsers.includes(user.did), + ) + .map(user => user.did) ?? [] + + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ + onMutate: () => { + logger.metric('onboarding:suggestedAccounts:followAllPressed', { + tab: selectedInterest ?? 'all', + numAccounts: followableDids.length, + }) + }, + mutationFn: async () => { + for (const did of followableDids) { + updateProfileShadow(queryClient, did, { + followingUri: 'pending', + }) + } + const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) + for (const did of followableDids) { + const uri = uris.get(did) + updateProfileShadow(queryClient, did, { + followingUri: uri, + }) + } + return followableDids + }, + onSuccess: newlyFollowed => { + toast.show(_(msg`Followed all accounts!`), {type: 'success'}) + setFollowedUsers(followed => [...followed, ...newlyFollowed]) + }, + onError: () => { + toast.show( + _(msg`Failed to follow all suggested accounts, please try again`), + {type: 'error'}, + ) + }, + }) + + const canFollowAll = followableDids.length > 0 && !isFollowingAll + + return ( + <View style={[a.align_start]} testID="onboardingInterests"> + <Text style={[a.font_heavy, a.text_3xl]}> + <Trans comment="Accounts suggested to the user for them to follow"> + Suggested for you + </Trans> + </Text> + + <View + style={[ + a.overflow_hidden, + a.mt_lg, + isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, + a.flex_1, + a.justify_start, + ]}> + <TabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + defaultTabLabel={_( + msg({ + message: 'All', + comment: 'the default tab in the interests tab bar', + }), + )} + selectedInterests={state.interestsStepResults.selectedInterests} + /> + + {isLoading || !moderationOpts ? ( + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> + ) : isError ? ( + <View style={[a.flex_1, a.px_xl, a.pt_5xl]}> + <Admonition type="error"> + <Trans> + An error occurred while fetching suggested accounts. + </Trans> + </Admonition> + </View> + ) : ( + <View + style={[ + a.flex_1, + a.mt_md, + a.border_y, + t.atoms.border_contrast_low, + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], + ]}> + {suggestedUsers?.actors.map((user, index) => ( + <SuggestedProfileCard + key={user.did} + profile={user} + moderationOpts={moderationOpts} + position={index} + /> + ))} + </View> + )} + </View> + + <OnboardingControls.Portal> + {isError ? ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={isRefetching} + color="secondary" + size="large" + label={_(msg`Retry`)} + onPress={() => refetch()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> + </Button> + <Button + color="secondary" + size="large" + label={_(msg`Skip this flow`)} + onPress={skipOnboarding}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </View> + ) : ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={!canFollowAll} + color="secondary" + size="large" + label={_(msg`Follow all accounts`)} + onPress={() => followAll()}> + <ButtonText> + <Trans>Follow all</Trans> + </ButtonText> + <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> + </Button> + <Button + disabled={isFollowingAll} + color="primary" + size="large" + label={_(msg`Continue to next step`)} + onPress={() => dispatch({type: 'next'})}> + <ButtonText> + <Trans>Continue</Trans> + </ButtonText> + </Button> + </View> + )} + </OnboardingControls.Portal> + </View> + ) +} + +function TabBar({ + selectedInterest, + onSelectInterest, + selectedInterests, + hideDefaultTab, + defaultTabLabel, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void + selectedInterests: string[] + hideDefaultTab?: boolean + defaultTabLabel?: string +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(selectedInterests)) + + return ( + <InterestTabs + interests={hideDefaultTab ? interests : ['all', ...interests]} + selectedInterest={ + selectedInterest || (hideDefaultTab ? interests[0] : 'all') + } + onSelectTab={tab => { + logger.metric( + 'onboarding:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + interestsDisplayNames={ + hideDefaultTab + ? interestsDisplayNames + : { + all: defaultTabLabel || _(msg`For You`), + ...interestsDisplayNames, + } + } + gutterWidth={isWeb ? 0 : tokens.space.xl} + /> + ) +} + +function SuggestedProfileCard({ + profile, + moderationOpts, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + position: number +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + a.w_full, + a.py_lg, + a.px_xl, + position !== 0 && a.border_t, + t.atoms.border_contrast_low, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + disabledPreview + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + withIcon={false} + logContext="OnboardingSuggestedAccounts" + onFollow={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Onboarding', + location: 'Card', + recId: undefined, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={3} /> + </ProfileCard.Outer> + </View> + ) +} |