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>
This commit is contained in:
parent
aa7117edb6
commit
0ed99b840d
19 changed files with 721 additions and 22 deletions
|
@ -34,4 +34,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
|||
userAge: 13, // TODO(pwi)
|
||||
interests: {tags: []},
|
||||
savedFeeds: [],
|
||||
bskyAppState: {
|
||||
queuedNudges: [],
|
||||
activeProgressGuide: undefined,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
185
src/state/shell/progress-guide.tsx
Normal file
185
src/state/shell/progress-guide.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue