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>
zio/stable
Paul Frazee 2024-07-03 19:05:19 -07:00 committed by GitHub
parent aa7117edb6
commit 0ed99b840d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 721 additions and 22 deletions

View File

@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.22", "@atproto/api": "^0.12.23",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -45,6 +45,7 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 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 SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
@ -119,10 +120,13 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<TourProvider> <TourProvider>
<GestureHandlerRootView style={s.h100pct}> <ProgressGuideProvider>
<TestCtrls /> <GestureHandlerRootView
<Shell /> style={s.h100pct}>
</GestureHandlerRootView> <TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider> </TourProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>

View File

@ -34,6 +34,7 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 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 SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@ -104,7 +105,9 @@ function InnerApp() {
<MutedThreadsProvider> <MutedThreadsProvider>
<SafeAreaProvider> <SafeAreaProvider>
<TourProvider> <TourProvider>
<Shell /> <ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider> </TourProvider>
</SafeAreaProvider> </SafeAreaProvider>
</MutedThreadsProvider> </MutedThreadsProvider>

View File

@ -6,11 +6,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 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 {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' 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 {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ProgressGuideList} from './ProgressGuide/List'
function CardOuter({ function CardOuter({
children, children,
@ -352,3 +355,26 @@ export function SuggestedFeeds() {
</View> </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
}

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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>
)
)
})

View File

@ -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>
)
})

View File

@ -7,5 +7,6 @@ export type Gate =
| 'show_avi_follow_button' | 'show_avi_follow_button'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'new_user_guided_tour' | 'new_user_guided_tour'
| 'new_user_progress_guide'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial' | 'suggested_follows_interstitial'

View File

@ -19,6 +19,7 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile' import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {uploadBlob} from 'lib/api' import {uploadBlob} from 'lib/api'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications' import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
@ -58,6 +59,7 @@ export function StepFinished() {
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const setQueuedTour = useSetQueuedTour() const setQueuedTour = useSetQueuedTour()
const {startProgressGuide} = useProgressGuideControls()
const finishOnboarding = React.useCallback(async () => { const finishOnboarding = React.useCallback(async () => {
setSaving(true) setSaving(true)
@ -185,6 +187,7 @@ export function StepFinished() {
setActiveStarterPack(undefined) setActiveStarterPack(undefined)
setHasCheckedForStarterPack(true) setHasCheckedForStarterPack(true)
setQueuedTour(TOURS.HOME) setQueuedTour(TOURS.HOME)
startProgressGuide('like-10-and-follow-7')
dispatch({type: 'finish'}) dispatch({type: 'finish'})
onboardDispatch({type: 'finish'}) onboardDispatch({type: 'finish'})
track('OnboardingV2:StepFinished:End') track('OnboardingV2:StepFinished:End')
@ -218,6 +221,7 @@ export function StepFinished() {
setActiveStarterPack, setActiveStarterPack,
setHasCheckedForStarterPack, setHasCheckedForStarterPack,
setQueuedTour, setQueuedTour,
startProgressGuide,
]) ])
React.useEffect(() => { React.useEffect(() => {

View File

@ -18,6 +18,10 @@ import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {batchedUpdates} from 'lib/batchedUpdates' import {batchedUpdates} from 'lib/batchedUpdates'
import {HITSLOP_20} from 'lib/constants' import {HITSLOP_20} from 'lib/constants'
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted' import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
@ -287,6 +291,7 @@ function Header({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
const {captureAction} = useProgressGuideControls()
const [isProcessing, setIsProcessing] = React.useState(false) const [isProcessing, setIsProcessing] = React.useState(false)
@ -351,6 +356,7 @@ function Header({
starterPack: starterPack.uri, starterPack: starterPack.uri,
count: dids.length, count: dids.length,
}) })
captureAction(ProgressGuideAction.Follow, dids.length)
Toast.show(_(msg`All accounts have been followed!`)) Toast.show(_(msg`All accounts have been followed!`))
} catch (e) { } catch (e) {
Toast.show(_(msg`An error occurred while trying to follow all`)) Toast.show(_(msg`An error occurred while trying to follow all`))

View File

@ -34,4 +34,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
userAge: 13, // TODO(pwi) userAge: 13, // TODO(pwi)
interests: {tags: []}, interests: {tags: []},
savedFeeds: [], savedFeeds: [],
bskyAppState: {
queuedNudges: [],
activeProgressGuide: undefined,
},
} }

View File

@ -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,
})
},
})
}

View File

@ -25,6 +25,10 @@ import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {updateProfileShadow} from '../cache/profile-shadow' import {updateProfileShadow} from '../cache/profile-shadow'
import {useAgent, useSession} from '../session' 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_LIST_CONVOS} from './messages/list-converations'
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
@ -274,12 +278,15 @@ function useProfileFollowMutation(
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {captureAction} = useProgressGuideControls()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({ return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
if (currentAccount) { if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did) ownProfile = findProfileQueryData(queryClient, currentAccount.did)
} }
captureAction(ProgressGuideAction.Follow)
logEvent('profile:follow', { logEvent('profile:follow', {
logContext, logContext,
didBecomeMutual: profile.viewer didBecomeMutual: profile.viewer

View 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>
)
}

View File

@ -34,7 +34,11 @@ import {useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useTheme} from 'lib/ThemeContext' 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 {List, ListRef} from '../util/List'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@ -85,12 +89,26 @@ type FeedItem =
} }
slot: number slot: number
} }
| {
type: 'interstitialProgressGuide'
key: string
params: {
variant: 'default' | string
}
slot: number
}
const feedInterstitialType = 'interstitialFeeds' const feedInterstitialType = 'interstitialFeeds'
const followInterstitialType = 'interstitialFollows' const followInterstitialType = 'interstitialFollows'
const progressGuideInterstitialType = 'interstitialProgressGuide'
const interstials: Record< const interstials: Record<
'following' | 'discover', 'following' | 'discover',
(FeedItem & {type: 'interstitialFeeds' | 'interstitialFollows'})[] (FeedItem & {
type:
| 'interstitialFeeds'
| 'interstitialFollows'
| 'interstitialProgressGuide'
})[]
> = { > = {
following: [ following: [
{ {
@ -111,6 +129,14 @@ const interstials: Record<
}, },
], ],
discover: [ discover: [
{
type: progressGuideInterstitialType,
params: {
variant: 'default',
},
key: progressGuideInterstitialType,
slot: 0,
},
{ {
type: feedInterstitialType, type: feedInterstitialType,
params: { params: {
@ -336,14 +362,14 @@ let Feed = ({
if (feedType) { if (feedType) {
for (const interstitial of interstials[feedType]) { for (const interstitial of interstials[feedType]) {
const feedInterstitialEnabled = const shouldShow =
interstitial.type === feedInterstitialType && (interstitial.type === feedInterstitialType &&
gate('suggested_feeds_interstitial') gate('suggested_feeds_interstitial')) ||
const followInterstitialEnabled = (interstitial.type === followInterstitialType &&
interstitial.type === followInterstitialType && gate('suggested_follows_interstitial')) ||
gate('suggested_follows_interstitial') interstitial.type === progressGuideInterstitialType
if (feedInterstitialEnabled || followInterstitialEnabled) { if (shouldShow) {
const variant = 'default' // replace with experiment variant const variant = 'default' // replace with experiment variant
const int = { const int = {
...interstitial, ...interstitial,
@ -460,6 +486,8 @@ let Feed = ({
return <SuggestedFeeds /> return <SuggestedFeeds />
} else if (item.type === followInterstitialType) { } else if (item.type === followInterstitialType) {
return <SuggestedFollows /> return <SuggestedFollows />
} else if (item.type === progressGuideInterstitialType) {
return <ProgressGuide />
} else if (item.type === 'slice') { } else if (item.type === 'slice') {
if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
// HACK // HACK

View File

@ -31,6 +31,10 @@ import {
} from '#/state/queries/post' } from '#/state/queries/post'
import {useRequireAuth, useSession} from '#/state/session' import {useRequireAuth, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
@ -77,6 +81,7 @@ let PostCtrls = ({
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext() const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const gate = useGate() const gate = useGate()
@ -103,6 +108,7 @@ let PostCtrls = ({
event: 'app.bsky.feed.defs#interactionLike', event: 'app.bsky.feed.defs#interactionLike',
feedContext, feedContext,
}) })
captureAction(ProgressGuideAction.Like)
await queueLike() await queueLike()
} else { } else {
await queueUnlike() await queueUnlike()
@ -119,6 +125,7 @@ let PostCtrls = ({
queueLike, queueLike,
queueUnlike, queueUnlike,
sendInteraction, sendInteraction,
captureAction,
feedContext, feedContext,
]) ])

View File

@ -14,6 +14,7 @@ import {Text} from 'view/com/util/text/Text'
import {DesktopFeeds} from './Feeds' import {DesktopFeeds} from './Feeds'
import {DesktopSearch} from './Search' import {DesktopSearch} from './Search'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {ProgressGuideList} from '#/components/ProgressGuide/List'
export function DesktopRightNav({routeName}: {routeName: string}) { export function DesktopRightNav({routeName}: {routeName: string}) {
const pal = usePalette('default') const pal = usePalette('default')
@ -39,9 +40,12 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
<DesktopSearch /> <DesktopSearch />
{hasSession && ( {hasSession && (
<View style={[pal.border, styles.desktopFeedsContainer]}> <>
<DesktopFeeds /> <ProgressGuideList style={[{marginTop: 22, marginBottom: 8}]} />
</View> <View style={[pal.border, styles.desktopFeedsContainer]}>
<DesktopFeeds />
</View>
</>
)} )}
</> </>
)} )}

View File

@ -34,15 +34,16 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.12.22": "@atproto/api@^0.12.23":
version "0.12.22" version "0.12.23"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22.tgz#1880a93a0caa4485cd8463bd1e10bf2424b9826c" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.23.tgz#b3409817d0b981a64f30d16e8257f0fe261338af"
integrity sha512-TIXSnf3qqyX40Ei/FkK4H24w+7s5rOc63TPwrGakRBOqIgSNBKOggei8I600fJ/AXB7HO6Vp9tBmDVOt2+021A== integrity sha512-fgQ30u+q9smX5g41eep7fISSkSAhRkX0inc81PZ82QwcHbFkC8ePaha/KP0CoTaPWKi7EsC89Z/8BEBCJo0oBA==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0" "@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0" "@atproto/xrpc" "^0.5.0"
await-lock "^2.2.2"
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"