about summary refs log tree commit diff
path: root/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-08-27 14:17:45 +0300
committerGitHub <noreply@github.com>2025-08-27 04:17:45 -0700
commiteac02901435d7bc79a28e0bff665352b814f9508 (patch)
tree8d770830a0c7081c5e4ed941b192da34ecb538a8 /src/screens/Onboarding/StepSuggestedAccounts/index.tsx
parent0617fca5ef1d30e7db49eb7dc8e66f6b586cc207 (diff)
downloadvoidsky-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.tsx356
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>
+  )
+}