about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-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
4 files changed, 243 insertions, 0 deletions
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>
+  )
+}