about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-07-03 19:05:19 -0700
committerGitHub <noreply@github.com>2024-07-04 03:05:19 +0100
commit0ed99b840d8de13465f010a6434dea50c72b3f62 (patch)
tree75ebec28653a081793ca0cbca0c428a816980c6a /src
parentaa7117edb60711a67464f7559118334185f01680 (diff)
downloadvoidsky-0ed99b840d8de13465f010a6434dea50c72b3f62.tar.zst
New user progress guides (#4716)
* Add the animated checkmark svg

* Add progress guide list and task components

* Add ProgressGuide Toast component

* Implement progress-guide controller

* Add 7 follows to the progress guide

* Wire up action captures

* Wire up progress-guide persistence

* Trigger progress guide on account creation

* Clear the progress guide from storage on complete

* Add progress guide interstitial, put behind gate

* Fix: read progress guide state from prefs

* Some defensive type checks

* Create separate toast for completion

* List tweaks

* Only show on Discover

* Spacing and progress tweaks

* Completely hide when complete

* Capture the progress guide in local state, and only render toasts while guide is active

* Fix: ensure persisted hydrates into local state

* Gate

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx12
-rw-r--r--src/App.web.tsx5
-rw-r--r--src/components/FeedInterstitials.tsx26
-rw-r--r--src/components/ProgressGuide/List.tsx61
-rw-r--r--src/components/ProgressGuide/Task.tsx50
-rw-r--r--src/components/ProgressGuide/Toast.tsx169
-rw-r--r--src/components/anim/AnimatedCheck.tsx92
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/screens/Onboarding/StepFinished.tsx4
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx6
-rw-r--r--src/state/queries/preferences/const.ts4
-rw-r--r--src/state/queries/preferences/index.ts47
-rw-r--r--src/state/queries/profile.ts7
-rw-r--r--src/state/shell/progress-guide.tsx185
-rw-r--r--src/view/com/posts/Feed.tsx46
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx7
-rw-r--r--src/view/shell/desktop/RightNav.tsx10
17 files changed, 715 insertions, 17 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 18af74409..f0dde6ee1 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -45,6 +45,7 @@ import {
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
@@ -119,10 +120,13 @@ function InnerApp() {
                                 <BackgroundNotificationPreferencesProvider>
                                   <MutedThreadsProvider>
                                     <TourProvider>
-                                      <GestureHandlerRootView style={s.h100pct}>
-                                        <TestCtrls />
-                                        <Shell />
-                                      </GestureHandlerRootView>
+                                      <ProgressGuideProvider>
+                                        <GestureHandlerRootView
+                                          style={s.h100pct}>
+                                          <TestCtrls />
+                                          <Shell />
+                                        </GestureHandlerRootView>
+                                      </ProgressGuideProvider>
                                     </TourProvider>
                                   </MutedThreadsProvider>
                                 </BackgroundNotificationPreferencesProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index f45806e4d..eb4a925d0 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -34,6 +34,7 @@ import {
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import * as Toast from '#/view/com/util/Toast'
@@ -104,7 +105,9 @@ function InnerApp() {
                                 <MutedThreadsProvider>
                                   <SafeAreaProvider>
                                     <TourProvider>
-                                      <Shell />
+                                      <ProgressGuideProvider>
+                                        <Shell />
+                                      </ProgressGuideProvider>
                                     </TourProvider>
                                   </SafeAreaProvider>
                                 </MutedThreadsProvider>
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 00342b39f..ca3b085b9 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -6,11 +6,13 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useGetPopularFeedsQuery} from '#/state/queries/feed'
 import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useProgressGuide} from '#/state/shell/progress-guide'
 import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
 import {Button} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
@@ -20,6 +22,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
 import {InlineLinkText} from '#/components/Link'
 import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
+import {ProgressGuideList} from './ProgressGuide/List'
 
 function CardOuter({
   children,
@@ -352,3 +355,26 @@ export function SuggestedFeeds() {
     </View>
   )
 }
+
+export function ProgressGuide() {
+  const t = useTheme()
+  const {isDesktop} = useWebMediaQueries()
+  const guide = useProgressGuide('like-10-and-follow-7')
+
+  if (isDesktop) {
+    return null
+  }
+
+  return guide ? (
+    <View
+      style={[
+        a.border_t,
+        t.atoms.border_contrast_low,
+        a.px_lg,
+        a.py_lg,
+        a.pb_lg,
+      ]}>
+      <ProgressGuideList />
+    </View>
+  ) : null
+}
diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx
new file mode 100644
index 000000000..f68445d2b
--- /dev/null
+++ b/src/components/ProgressGuide/List.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+  useProgressGuide,
+  useProgressGuideControls,
+} from '#/state/shell/progress-guide'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+import {ProgressGuideTask} from './Task'
+
+export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const guide = useProgressGuide('like-10-and-follow-7')
+  const {endProgressGuide} = useProgressGuideControls()
+
+  if (guide) {
+    return (
+      <View style={[a.flex_col, a.gap_md, style]}>
+        <View style={[a.flex_row, a.align_center, a.justify_between]}>
+          <Text
+            style={[
+              t.atoms.text_contrast_medium,
+              a.font_semibold,
+              a.text_sm,
+              {textTransform: 'uppercase'},
+            ]}>
+            <Trans>Getting started</Trans>
+          </Text>
+          <Button
+            variant="ghost"
+            size="tiny"
+            color="secondary"
+            shape="round"
+            label={_(msg`Dismiss getting started guide`)}
+            onPress={endProgressGuide}>
+            <ButtonIcon icon={Times} size="sm" />
+          </Button>
+        </View>
+        <ProgressGuideTask
+          current={guide.numLikes + 1}
+          total={10 + 1}
+          title={_(msg`Like 10 posts`)}
+          subtitle={_(msg`Teach our algorithm what you like`)}
+        />
+        <ProgressGuideTask
+          current={guide.numFollows + 1}
+          total={7 + 1}
+          title={_(msg`Follow 7 accounts`)}
+          subtitle={_(msg`Bluesky is better with friends!`)}
+        />
+      </View>
+    )
+  }
+  return null
+}
diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx
new file mode 100644
index 000000000..d286b8842
--- /dev/null
+++ b/src/components/ProgressGuide/Task.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View} from 'react-native'
+import * as Progress from 'react-native-progress'
+
+import {atoms as a, useTheme} from '#/alf'
+import {AnimatedCheck} from '../anim/AnimatedCheck'
+import {Text} from '../Typography'
+
+export function ProgressGuideTask({
+  current,
+  total,
+  title,
+  subtitle,
+}: {
+  current: number
+  total: number
+  title: string
+  subtitle?: string
+}) {
+  const t = useTheme()
+
+  return (
+    <View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
+      {current === total ? (
+        <AnimatedCheck playOnMount fill={t.palette.primary_500} width={24} />
+      ) : (
+        <Progress.Circle
+          progress={current / total}
+          color={t.palette.primary_400}
+          size={20}
+          thickness={3}
+          borderWidth={0}
+          unfilledColor={t.palette.contrast_50}
+        />
+      )}
+
+      <View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
+        <Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
+          {title}
+        </Text>
+        {subtitle && (
+          <Text
+            style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
+            {subtitle}
+          </Text>
+        )}
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ProgressGuide/Toast.tsx b/src/components/ProgressGuide/Toast.tsx
new file mode 100644
index 000000000..346312af5
--- /dev/null
+++ b/src/components/ProgressGuide/Toast.tsx
@@ -0,0 +1,169 @@
+import React, {useImperativeHandle} from 'react'
+import {Pressable, useWindowDimensions, View} from 'react-native'
+import Animated, {
+  Easing,
+  runOnJS,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {Portal} from '#/components/Portal'
+import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
+import {Text} from '../Typography'
+
+export interface ProgressGuideToastRef {
+  open(): void
+  close(): void
+}
+
+export interface ProgressGuideToastProps {
+  title: string
+  subtitle?: string
+  visibleDuration?: number // default 5s
+}
+
+export const ProgressGuideToast = React.forwardRef<
+  ProgressGuideToastRef,
+  ProgressGuideToastProps
+>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const insets = useSafeAreaInsets()
+  const [isOpen, setIsOpen] = React.useState(false)
+  const translateY = useSharedValue(0)
+  const opacity = useSharedValue(0)
+  const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
+  const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
+  const winDim = useWindowDimensions()
+
+  /**
+   * Methods
+   */
+
+  const close = React.useCallback(() => {
+    // clear the timeout, in case this was called imperatively
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current)
+      timeoutRef.current = undefined
+    }
+
+    // animate the opacity then set isOpen to false when done
+    const setIsntOpen = () => setIsOpen(false)
+    opacity.value = withTiming(
+      0,
+      {
+        duration: 400,
+        easing: Easing.out(Easing.cubic),
+      },
+      () => runOnJS(setIsntOpen)(),
+    )
+  }, [setIsOpen, opacity])
+
+  const open = React.useCallback(() => {
+    // set isOpen=true to render
+    setIsOpen(true)
+
+    // animate the vertical translation, the opacity, and the checkmark
+    const playCheckmark = () => animatedCheckRef.current?.play()
+    opacity.value = 0
+    opacity.value = withTiming(
+      1,
+      {
+        duration: 100,
+        easing: Easing.out(Easing.cubic),
+      },
+      () => runOnJS(playCheckmark)(),
+    )
+    translateY.value = 0
+    translateY.value = withTiming(insets.top + 10, {
+      duration: 500,
+      easing: Easing.out(Easing.cubic),
+    })
+
+    // start the countdown timer to autoclose
+    timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
+  }, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
+
+  useImperativeHandle(
+    ref,
+    () => ({
+      open,
+      close,
+    }),
+    [open, close],
+  )
+
+  const containerStyle = React.useMemo(() => {
+    let left = 10
+    let right = 10
+    if (isWeb && winDim.width > 400) {
+      left = right = (winDim.width - 380) / 2
+    }
+    return {
+      position: isWeb ? 'fixed' : 'absolute',
+      top: 0,
+      left,
+      right,
+    }
+  }, [winDim.width])
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    transform: [{translateY: translateY.value}],
+    opacity: opacity.value,
+  }))
+
+  return (
+    isOpen && (
+      <Portal>
+        <Animated.View
+          style={[
+            // @ts-ignore position: fixed is web only
+            containerStyle,
+            animatedStyle,
+          ]}>
+          <Pressable
+            style={[
+              t.atoms.bg,
+              a.flex_row,
+              a.align_center,
+              a.gap_md,
+              a.border,
+              t.atoms.border_contrast_high,
+              a.rounded_md,
+              a.px_lg,
+              a.py_md,
+              a.shadow_sm,
+              {
+                shadowRadius: 8,
+                shadowOpacity: 0.1,
+                shadowOffset: {width: 0, height: 2},
+                elevation: 8,
+              },
+            ]}
+            onPress={close}
+            accessibilityLabel={_(msg`Tap to dismiss`)}
+            accessibilityHint="">
+            <AnimatedCheck
+              fill={t.palette.primary_500}
+              ref={animatedCheckRef}
+            />
+            <View>
+              <Text style={[a.text_md, a.font_semibold]}>{title}</Text>
+              {subtitle && (
+                <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+                  {subtitle}
+                </Text>
+              )}
+            </View>
+          </Pressable>
+        </Animated.View>
+      </Portal>
+    )
+  )
+})
diff --git a/src/components/anim/AnimatedCheck.tsx b/src/components/anim/AnimatedCheck.tsx
new file mode 100644
index 000000000..7fdfc14cf
--- /dev/null
+++ b/src/components/anim/AnimatedCheck.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import Animated, {
+  Easing,
+  useAnimatedProps,
+  useSharedValue,
+  withDelay,
+  withTiming,
+} from 'react-native-reanimated'
+import Svg, {Circle, Path} from 'react-native-svg'
+
+import {Props, useCommonSVGProps} from '#/components/icons/common'
+
+const AnimatedPath = Animated.createAnimatedComponent(Path)
+const AnimatedCircle = Animated.createAnimatedComponent(Circle)
+
+const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
+
+export interface AnimatedCheckRef {
+  play(cb?: () => void): void
+}
+
+export interface AnimatedCheckProps extends Props {
+  playOnMount?: boolean
+}
+
+export const AnimatedCheck = React.forwardRef<
+  AnimatedCheckRef,
+  AnimatedCheckProps
+>(function AnimatedCheck({playOnMount, ...props}, ref) {
+  const {fill, size, style, ...rest} = useCommonSVGProps(props)
+  const circleAnim = useSharedValue(0)
+  const checkAnim = useSharedValue(0)
+
+  const circleAnimatedProps = useAnimatedProps(() => ({
+    strokeDashoffset: 166 - circleAnim.value * 166,
+  }))
+  const checkAnimatedProps = useAnimatedProps(() => ({
+    strokeDashoffset: 48 - 48 * checkAnim.value,
+  }))
+
+  const play = React.useCallback(
+    (cb?: () => void) => {
+      circleAnim.value = 0
+      checkAnim.value = 0
+
+      circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
+      checkAnim.value = withDelay(
+        500,
+        withTiming(1, {duration: 300, easing: Easing.linear}, cb),
+      )
+    },
+    [circleAnim, checkAnim],
+  )
+
+  React.useImperativeHandle(ref, () => ({
+    play,
+  }))
+
+  React.useEffect(() => {
+    if (playOnMount) {
+      play()
+    }
+  }, [play, playOnMount])
+
+  return (
+    <Svg
+      fill="none"
+      {...rest}
+      viewBox="0 0 52 52"
+      width={size}
+      height={size}
+      style={style}>
+      <AnimatedCircle
+        animatedProps={circleAnimatedProps}
+        cx="26"
+        cy="26"
+        r="24"
+        fill="none"
+        stroke={fill}
+        strokeWidth={4}
+        strokeDasharray={166}
+      />
+      <AnimatedPath
+        animatedProps={checkAnimatedProps}
+        stroke={fill}
+        d={PATH}
+        strokeWidth={4}
+        strokeDasharray={48}
+      />
+    </Svg>
+  )
+})
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index e4991ad38..c8a55b928 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -7,5 +7,6 @@ export type Gate =
   | 'show_avi_follow_button'
   | 'show_follow_back_label_v2'
   | 'new_user_guided_tour'
+  | 'new_user_progress_guide'
   | 'suggested_feeds_interstitial'
   | 'suggested_follows_interstitial'
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 1cb925c1f..825a0e723 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -19,6 +19,7 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
 import {RQKEY as profileRQKey} from '#/state/queries/profile'
 import {useAgent} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
+import {useProgressGuideControls} from '#/state/shell/progress-guide'
 import {uploadBlob} from 'lib/api'
 import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
 import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
@@ -58,6 +59,7 @@ export function StepFinished() {
   const setActiveStarterPack = useSetActiveStarterPack()
   const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
   const setQueuedTour = useSetQueuedTour()
+  const {startProgressGuide} = useProgressGuideControls()
 
   const finishOnboarding = React.useCallback(async () => {
     setSaving(true)
@@ -185,6 +187,7 @@ export function StepFinished() {
     setActiveStarterPack(undefined)
     setHasCheckedForStarterPack(true)
     setQueuedTour(TOURS.HOME)
+    startProgressGuide('like-10-and-follow-7')
     dispatch({type: 'finish'})
     onboardDispatch({type: 'finish'})
     track('OnboardingV2:StepFinished:End')
@@ -218,6 +221,7 @@ export function StepFinished() {
     setActiveStarterPack,
     setHasCheckedForStarterPack,
     setQueuedTour,
+    startProgressGuide,
   ])
 
   React.useEffect(() => {
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
index 9b66e5157..518318f7a 100644
--- a/src/screens/StarterPack/StarterPackScreen.tsx
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -18,6 +18,10 @@ import {useQueryClient} from '@tanstack/react-query'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
 import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
+import {
+  ProgressGuideAction,
+  useProgressGuideControls,
+} from '#/state/shell/progress-guide'
 import {batchedUpdates} from 'lib/batchedUpdates'
 import {HITSLOP_20} from 'lib/constants'
 import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
@@ -287,6 +291,7 @@ function Header({
   const queryClient = useQueryClient()
   const setActiveStarterPack = useSetActiveStarterPack()
   const {requestSwitchToAccount} = useLoggedOutViewControls()
+  const {captureAction} = useProgressGuideControls()
 
   const [isProcessing, setIsProcessing] = React.useState(false)
 
@@ -351,6 +356,7 @@ function Header({
         starterPack: starterPack.uri,
         count: dids.length,
       })
+      captureAction(ProgressGuideAction.Follow, dids.length)
       Toast.show(_(msg`All accounts have been followed!`))
     } catch (e) {
       Toast.show(_(msg`An error occurred while trying to follow all`))
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index d94edb47e..2a8c51165 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -34,4 +34,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   userAge: 13, // TODO(pwi)
   interests: {tags: []},
   savedFeeds: [],
+  bskyAppState: {
+    queuedNudges: [],
+    activeProgressGuide: undefined,
+  },
 }
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 672abfcac..9bb57fcaf 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -342,3 +342,50 @@ export function useRemoveMutedWordMutation() {
     },
   })
 }
+
+export function useQueueNudgesMutation() {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async (nudges: string | string[]) => {
+      await agent.bskyAppQueueNudges(nudges)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useDismissNudgesMutation() {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async (nudges: string | string[]) => {
+      await agent.bskyAppDismissNudges(nudges)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetActiveProgressGuideMutation() {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async (
+      guide: AppBskyActorDefs.BskyAppProgressGuide | undefined,
+    ) => {
+      await agent.bskyAppSetActiveProgressGuide(guide)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 6f7f2de79..af00faf27 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -25,6 +25,10 @@ import {STALE} from '#/state/queries'
 import {resetProfilePostsQueries} from '#/state/queries/post-feed'
 import {updateProfileShadow} from '../cache/profile-shadow'
 import {useAgent, useSession} from '../session'
+import {
+  ProgressGuideAction,
+  useProgressGuideControls,
+} from '../shell/progress-guide'
 import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-converations'
 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
 import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
@@ -274,12 +278,15 @@ function useProfileFollowMutation(
   const {currentAccount} = useSession()
   const agent = useAgent()
   const queryClient = useQueryClient()
+  const {captureAction} = useProgressGuideControls()
+
   return useMutation<{uri: string; cid: string}, Error, {did: string}>({
     mutationFn: async ({did}) => {
       let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
       if (currentAccount) {
         ownProfile = findProfileQueryData(queryClient, currentAccount.did)
       }
+      captureAction(ProgressGuideAction.Follow)
       logEvent('profile:follow', {
         logContext,
         didBecomeMutual: profile.viewer
diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx
new file mode 100644
index 000000000..d10d58297
--- /dev/null
+++ b/src/state/shell/progress-guide.tsx
@@ -0,0 +1,185 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {
+  ProgressGuideToast,
+  ProgressGuideToastRef,
+} from '#/components/ProgressGuide/Toast'
+import {
+  usePreferencesQuery,
+  useSetActiveProgressGuideMutation,
+} from '../queries/preferences'
+
+export enum ProgressGuideAction {
+  Like = 'like',
+  Follow = 'follow',
+}
+
+type ProgressGuideName = 'like-10-and-follow-7'
+
+interface BaseProgressGuide {
+  guide: string
+  isComplete: boolean
+  [key: string]: any
+}
+
+interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
+  numLikes: number
+  numFollows: number
+}
+
+type ProgressGuide = Like10AndFollow7ProgressGuide | undefined
+
+const ProgressGuideContext = React.createContext<ProgressGuide>(undefined)
+
+const ProgressGuideControlContext = React.createContext<{
+  startProgressGuide(guide: ProgressGuideName): void
+  endProgressGuide(): void
+  captureAction(action: ProgressGuideAction, count?: number): void
+}>({
+  startProgressGuide: (_guide: ProgressGuideName) => {},
+  endProgressGuide: () => {},
+  captureAction: (_action: ProgressGuideAction, _count = 1) => {},
+})
+
+export function useProgressGuide(guide: ProgressGuideName) {
+  const ctx = React.useContext(ProgressGuideContext)
+  if (ctx?.guide === guide) {
+    return ctx
+  }
+  return undefined
+}
+
+export function useProgressGuideControls() {
+  return React.useContext(ProgressGuideControlContext)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const {_} = useLingui()
+  const {data: preferences} = usePreferencesQuery()
+  const {mutateAsync, variables} = useSetActiveProgressGuideMutation()
+  const gate = useGate()
+
+  const activeProgressGuide = (variables ||
+    preferences?.bskyAppState?.activeProgressGuide) as ProgressGuide
+
+  // ensure the unspecced attributes have the correct types
+  if (activeProgressGuide?.guide === 'like-10-and-follow-7') {
+    activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0
+    activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0
+  }
+
+  const [localGuideState, setLocalGuideState] =
+    React.useState<ProgressGuide>(undefined)
+
+  if (activeProgressGuide && !localGuideState) {
+    // hydrate from the server if needed
+    setLocalGuideState(activeProgressGuide)
+  }
+
+  const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
+  const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
+  const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
+  const guideCompleteToastRef = React.useRef<ProgressGuideToastRef | null>(null)
+
+  const controls = React.useMemo(() => {
+    return {
+      startProgressGuide(guide: ProgressGuideName) {
+        if (!gate('new_user_progress_guide')) {
+          return
+        }
+        if (guide === 'like-10-and-follow-7') {
+          const guideObj = {
+            guide: 'like-10-and-follow-7',
+            numLikes: 0,
+            numFollows: 0,
+            isComplete: false,
+          }
+          setLocalGuideState(guideObj)
+          mutateAsync(guideObj)
+        }
+      },
+
+      endProgressGuide() {
+        // update the persisted first
+        mutateAsync(undefined).then(() => {
+          // now clear local state, to avoid rehydrating from the server
+          setLocalGuideState(undefined)
+        })
+      },
+
+      captureAction(action: ProgressGuideAction, count = 1) {
+        let guide = activeProgressGuide
+        if (!guide || guide?.isComplete) {
+          return
+        }
+        if (guide?.guide === 'like-10-and-follow-7') {
+          if (action === ProgressGuideAction.Like) {
+            guide = {
+              ...guide,
+              numLikes: (Number(guide.numLikes) || 0) + count,
+            }
+            if (guide.numLikes === 1) {
+              firstLikeToastRef.current?.open()
+            }
+            if (guide.numLikes === 5) {
+              fifthLikeToastRef.current?.open()
+            }
+            if (guide.numLikes === 10) {
+              tenthLikeToastRef.current?.open()
+            }
+          }
+          if (action === ProgressGuideAction.Follow) {
+            guide = {
+              ...guide,
+              numFollows: (Number(guide.numFollows) || 0) + count,
+            }
+          }
+          if (Number(guide.numLikes) >= 10 && Number(guide.numFollows) >= 7) {
+            guide = {
+              ...guide,
+              isComplete: true,
+            }
+          }
+        }
+
+        setLocalGuideState(guide)
+        mutateAsync(guide?.isComplete ? undefined : guide)
+      },
+    }
+  }, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
+
+  return (
+    <ProgressGuideContext.Provider value={localGuideState}>
+      <ProgressGuideControlContext.Provider value={controls}>
+        {children}
+        {localGuideState?.guide === 'like-10-and-follow-7' && (
+          <>
+            <ProgressGuideToast
+              ref={firstLikeToastRef}
+              title={_(msg`Your first like!`)}
+              subtitle={_(msg`Like 10 posts to train the Discover feed`)}
+            />
+            <ProgressGuideToast
+              ref={fifthLikeToastRef}
+              title={_(msg`Half way there!`)}
+              subtitle={_(msg`Like 10 posts to train the Discover feed`)}
+            />
+            <ProgressGuideToast
+              ref={tenthLikeToastRef}
+              title={_(msg`Task complete - 10 likes!`)}
+              subtitle={_(msg`The Discover feed now knows what you like`)}
+            />
+            <ProgressGuideToast
+              ref={guideCompleteToastRef}
+              title={_(msg`Algorithm training complete!`)}
+              subtitle={_(msg`The Discover feed now knows what you like`)}
+            />
+          </>
+        )}
+      </ProgressGuideControlContext.Provider>
+    </ProgressGuideContext.Provider>
+  )
+}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 3d90b8897..e6ad35610 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -34,7 +34,11 @@ import {useSession} from '#/state/session'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
 import {useTheme} from 'lib/ThemeContext'
-import {SuggestedFeeds, SuggestedFollows} from '#/components/FeedInterstitials'
+import {
+  ProgressGuide,
+  SuggestedFeeds,
+  SuggestedFollows,
+} from '#/components/FeedInterstitials'
 import {List, ListRef} from '../util/List'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -85,12 +89,26 @@ type FeedItem =
       }
       slot: number
     }
+  | {
+      type: 'interstitialProgressGuide'
+      key: string
+      params: {
+        variant: 'default' | string
+      }
+      slot: number
+    }
 
 const feedInterstitialType = 'interstitialFeeds'
 const followInterstitialType = 'interstitialFollows'
+const progressGuideInterstitialType = 'interstitialProgressGuide'
 const interstials: Record<
   'following' | 'discover',
-  (FeedItem & {type: 'interstitialFeeds' | 'interstitialFollows'})[]
+  (FeedItem & {
+    type:
+      | 'interstitialFeeds'
+      | 'interstitialFollows'
+      | 'interstitialProgressGuide'
+  })[]
 > = {
   following: [
     {
@@ -112,6 +130,14 @@ const interstials: Record<
   ],
   discover: [
     {
+      type: progressGuideInterstitialType,
+      params: {
+        variant: 'default',
+      },
+      key: progressGuideInterstitialType,
+      slot: 0,
+    },
+    {
       type: feedInterstitialType,
       params: {
         variant: 'default',
@@ -336,14 +362,14 @@ let Feed = ({
 
       if (feedType) {
         for (const interstitial of interstials[feedType]) {
-          const feedInterstitialEnabled =
-            interstitial.type === feedInterstitialType &&
-            gate('suggested_feeds_interstitial')
-          const followInterstitialEnabled =
-            interstitial.type === followInterstitialType &&
-            gate('suggested_follows_interstitial')
+          const shouldShow =
+            (interstitial.type === feedInterstitialType &&
+              gate('suggested_feeds_interstitial')) ||
+            (interstitial.type === followInterstitialType &&
+              gate('suggested_follows_interstitial')) ||
+            interstitial.type === progressGuideInterstitialType
 
-          if (feedInterstitialEnabled || followInterstitialEnabled) {
+          if (shouldShow) {
             const variant = 'default' // replace with experiment variant
             const int = {
               ...interstitial,
@@ -460,6 +486,8 @@ let Feed = ({
         return <SuggestedFeeds />
       } else if (item.type === followInterstitialType) {
         return <SuggestedFollows />
+      } else if (item.type === progressGuideInterstitialType) {
+        return <ProgressGuide />
       } else if (item.type === 'slice') {
         if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
           // HACK
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 231808bf2..c3af3a61e 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -31,6 +31,10 @@ import {
 } from '#/state/queries/post'
 import {useRequireAuth, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
+import {
+  ProgressGuideAction,
+  useProgressGuideControls,
+} from '#/state/shell/progress-guide'
 import {atoms as a, useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
@@ -77,6 +81,7 @@ let PostCtrls = ({
   const requireAuth = useRequireAuth()
   const loggedOutWarningPromptControl = useDialogControl()
   const {sendInteraction} = useFeedFeedbackContext()
+  const {captureAction} = useProgressGuideControls()
   const playHaptic = useHaptics()
   const gate = useGate()
 
@@ -103,6 +108,7 @@ let PostCtrls = ({
           event: 'app.bsky.feed.defs#interactionLike',
           feedContext,
         })
+        captureAction(ProgressGuideAction.Like)
         await queueLike()
       } else {
         await queueUnlike()
@@ -119,6 +125,7 @@ let PostCtrls = ({
     queueLike,
     queueUnlike,
     sendInteraction,
+    captureAction,
     feedContext,
   ])
 
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 633f04932..8dfa671cf 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -14,6 +14,7 @@ import {Text} from 'view/com/util/text/Text'
 import {DesktopFeeds} from './Feeds'
 import {DesktopSearch} from './Search'
 import hairlineWidth = StyleSheet.hairlineWidth
+import {ProgressGuideList} from '#/components/ProgressGuide/List'
 
 export function DesktopRightNav({routeName}: {routeName: string}) {
   const pal = usePalette('default')
@@ -39,9 +40,12 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
             <DesktopSearch />
 
             {hasSession && (
-              <View style={[pal.border, styles.desktopFeedsContainer]}>
-                <DesktopFeeds />
-              </View>
+              <>
+                <ProgressGuideList style={[{marginTop: 22, marginBottom: 8}]} />
+                <View style={[pal.border, styles.desktopFeedsContainer]}>
+                  <DesktopFeeds />
+                </View>
+              </>
             )}
           </>
         )}