Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Paul Frazee
Eric Bailey
Dan Abramov
and committed by
GitHub
0ed99b84 aa7117ed

+721 -22
+1 -1
package.json
··· 49 49 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 50 50 }, 51 51 "dependencies": { 52 - "@atproto/api": "^0.12.22", 52 + "@atproto/api": "^0.12.23", 53 53 "@bam.tech/react-native-image-resizer": "^3.0.4", 54 54 "@braintree/sanitize-url": "^6.0.2", 55 55 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+8 -4
src/App.native.tsx
··· 45 45 import {readLastActiveAccount} from '#/state/session/util' 46 46 import {Provider as ShellStateProvider} from '#/state/shell' 47 47 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 48 + import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 48 49 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 49 50 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 50 51 import {TestCtrls} from '#/view/com/testing/TestCtrls' ··· 119 120 <BackgroundNotificationPreferencesProvider> 120 121 <MutedThreadsProvider> 121 122 <TourProvider> 122 - <GestureHandlerRootView style={s.h100pct}> 123 - <TestCtrls /> 124 - <Shell /> 125 - </GestureHandlerRootView> 123 + <ProgressGuideProvider> 124 + <GestureHandlerRootView 125 + style={s.h100pct}> 126 + <TestCtrls /> 127 + <Shell /> 128 + </GestureHandlerRootView> 129 + </ProgressGuideProvider> 126 130 </TourProvider> 127 131 </MutedThreadsProvider> 128 132 </BackgroundNotificationPreferencesProvider>
+4 -1
src/App.web.tsx
··· 34 34 import {readLastActiveAccount} from '#/state/session/util' 35 35 import {Provider as ShellStateProvider} from '#/state/shell' 36 36 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 37 + import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 37 38 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 38 39 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 39 40 import * as Toast from '#/view/com/util/Toast' ··· 104 105 <MutedThreadsProvider> 105 106 <SafeAreaProvider> 106 107 <TourProvider> 107 - <Shell /> 108 + <ProgressGuideProvider> 109 + <Shell /> 110 + </ProgressGuideProvider> 108 111 </TourProvider> 109 112 </SafeAreaProvider> 110 113 </MutedThreadsProvider>
+26
src/components/FeedInterstitials.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 import {useNavigation} from '@react-navigation/native' 8 8 9 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 9 10 import {NavigationProp} from '#/lib/routes/types' 10 11 import {logEvent} from '#/lib/statsig/statsig' 11 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 12 13 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 13 14 import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 15 + import {useProgressGuide} from '#/state/shell/progress-guide' 14 16 import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf' 15 17 import {Button} from '#/components/Button' 16 18 import * as FeedCard from '#/components/FeedCard' ··· 20 22 import {InlineLinkText} from '#/components/Link' 21 23 import * as ProfileCard from '#/components/ProfileCard' 22 24 import {Text} from '#/components/Typography' 25 + import {ProgressGuideList} from './ProgressGuide/List' 23 26 24 27 function CardOuter({ 25 28 children, ··· 352 355 </View> 353 356 ) 354 357 } 358 + 359 + export function ProgressGuide() { 360 + const t = useTheme() 361 + const {isDesktop} = useWebMediaQueries() 362 + const guide = useProgressGuide('like-10-and-follow-7') 363 + 364 + if (isDesktop) { 365 + return null 366 + } 367 + 368 + return guide ? ( 369 + <View 370 + style={[ 371 + a.border_t, 372 + t.atoms.border_contrast_low, 373 + a.px_lg, 374 + a.py_lg, 375 + a.pb_lg, 376 + ]}> 377 + <ProgressGuideList /> 378 + </View> 379 + ) : null 380 + }
+61
src/components/ProgressGuide/List.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import { 7 + useProgressGuide, 8 + useProgressGuideControls, 9 + } from '#/state/shell/progress-guide' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Button, ButtonIcon} from '#/components/Button' 12 + import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 13 + import {Text} from '#/components/Typography' 14 + import {ProgressGuideTask} from './Task' 15 + 16 + export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { 17 + const t = useTheme() 18 + const {_} = useLingui() 19 + const guide = useProgressGuide('like-10-and-follow-7') 20 + const {endProgressGuide} = useProgressGuideControls() 21 + 22 + if (guide) { 23 + return ( 24 + <View style={[a.flex_col, a.gap_md, style]}> 25 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 26 + <Text 27 + style={[ 28 + t.atoms.text_contrast_medium, 29 + a.font_semibold, 30 + a.text_sm, 31 + {textTransform: 'uppercase'}, 32 + ]}> 33 + <Trans>Getting started</Trans> 34 + </Text> 35 + <Button 36 + variant="ghost" 37 + size="tiny" 38 + color="secondary" 39 + shape="round" 40 + label={_(msg`Dismiss getting started guide`)} 41 + onPress={endProgressGuide}> 42 + <ButtonIcon icon={Times} size="sm" /> 43 + </Button> 44 + </View> 45 + <ProgressGuideTask 46 + current={guide.numLikes + 1} 47 + total={10 + 1} 48 + title={_(msg`Like 10 posts`)} 49 + subtitle={_(msg`Teach our algorithm what you like`)} 50 + /> 51 + <ProgressGuideTask 52 + current={guide.numFollows + 1} 53 + total={7 + 1} 54 + title={_(msg`Follow 7 accounts`)} 55 + subtitle={_(msg`Bluesky is better with friends!`)} 56 + /> 57 + </View> 58 + ) 59 + } 60 + return null 61 + }
+50
src/components/ProgressGuide/Task.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import * as Progress from 'react-native-progress' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {AnimatedCheck} from '../anim/AnimatedCheck' 7 + import {Text} from '../Typography' 8 + 9 + export function ProgressGuideTask({ 10 + current, 11 + total, 12 + title, 13 + subtitle, 14 + }: { 15 + current: number 16 + total: number 17 + title: string 18 + subtitle?: string 19 + }) { 20 + const t = useTheme() 21 + 22 + return ( 23 + <View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}> 24 + {current === total ? ( 25 + <AnimatedCheck playOnMount fill={t.palette.primary_500} width={24} /> 26 + ) : ( 27 + <Progress.Circle 28 + progress={current / total} 29 + color={t.palette.primary_400} 30 + size={20} 31 + thickness={3} 32 + borderWidth={0} 33 + unfilledColor={t.palette.contrast_50} 34 + /> 35 + )} 36 + 37 + <View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}> 38 + <Text style={[a.text_sm, a.font_semibold, a.leading_tight]}> 39 + {title} 40 + </Text> 41 + {subtitle && ( 42 + <Text 43 + style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}> 44 + {subtitle} 45 + </Text> 46 + )} 47 + </View> 48 + </View> 49 + ) 50 + }
+169
src/components/ProgressGuide/Toast.tsx
··· 1 + import React, {useImperativeHandle} from 'react' 2 + import {Pressable, useWindowDimensions, View} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + runOnJS, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withTiming, 9 + } from 'react-native-reanimated' 10 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 11 + import {msg} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + 14 + import {isWeb} from '#/platform/detection' 15 + import {atoms as a, useTheme} from '#/alf' 16 + import {Portal} from '#/components/Portal' 17 + import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck' 18 + import {Text} from '../Typography' 19 + 20 + export interface ProgressGuideToastRef { 21 + open(): void 22 + close(): void 23 + } 24 + 25 + export interface ProgressGuideToastProps { 26 + title: string 27 + subtitle?: string 28 + visibleDuration?: number // default 5s 29 + } 30 + 31 + export const ProgressGuideToast = React.forwardRef< 32 + ProgressGuideToastRef, 33 + ProgressGuideToastProps 34 + >(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) { 35 + const t = useTheme() 36 + const {_} = useLingui() 37 + const insets = useSafeAreaInsets() 38 + const [isOpen, setIsOpen] = React.useState(false) 39 + const translateY = useSharedValue(0) 40 + const opacity = useSharedValue(0) 41 + const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null) 42 + const timeoutRef = React.useRef<NodeJS.Timeout | undefined>() 43 + const winDim = useWindowDimensions() 44 + 45 + /** 46 + * Methods 47 + */ 48 + 49 + const close = React.useCallback(() => { 50 + // clear the timeout, in case this was called imperatively 51 + if (timeoutRef.current) { 52 + clearTimeout(timeoutRef.current) 53 + timeoutRef.current = undefined 54 + } 55 + 56 + // animate the opacity then set isOpen to false when done 57 + const setIsntOpen = () => setIsOpen(false) 58 + opacity.value = withTiming( 59 + 0, 60 + { 61 + duration: 400, 62 + easing: Easing.out(Easing.cubic), 63 + }, 64 + () => runOnJS(setIsntOpen)(), 65 + ) 66 + }, [setIsOpen, opacity]) 67 + 68 + const open = React.useCallback(() => { 69 + // set isOpen=true to render 70 + setIsOpen(true) 71 + 72 + // animate the vertical translation, the opacity, and the checkmark 73 + const playCheckmark = () => animatedCheckRef.current?.play() 74 + opacity.value = 0 75 + opacity.value = withTiming( 76 + 1, 77 + { 78 + duration: 100, 79 + easing: Easing.out(Easing.cubic), 80 + }, 81 + () => runOnJS(playCheckmark)(), 82 + ) 83 + translateY.value = 0 84 + translateY.value = withTiming(insets.top + 10, { 85 + duration: 500, 86 + easing: Easing.out(Easing.cubic), 87 + }) 88 + 89 + // start the countdown timer to autoclose 90 + timeoutRef.current = setTimeout(close, visibleDuration || 5e3) 91 + }, [setIsOpen, translateY, opacity, insets, close, visibleDuration]) 92 + 93 + useImperativeHandle( 94 + ref, 95 + () => ({ 96 + open, 97 + close, 98 + }), 99 + [open, close], 100 + ) 101 + 102 + const containerStyle = React.useMemo(() => { 103 + let left = 10 104 + let right = 10 105 + if (isWeb && winDim.width > 400) { 106 + left = right = (winDim.width - 380) / 2 107 + } 108 + return { 109 + position: isWeb ? 'fixed' : 'absolute', 110 + top: 0, 111 + left, 112 + right, 113 + } 114 + }, [winDim.width]) 115 + 116 + const animatedStyle = useAnimatedStyle(() => ({ 117 + transform: [{translateY: translateY.value}], 118 + opacity: opacity.value, 119 + })) 120 + 121 + return ( 122 + isOpen && ( 123 + <Portal> 124 + <Animated.View 125 + style={[ 126 + // @ts-ignore position: fixed is web only 127 + containerStyle, 128 + animatedStyle, 129 + ]}> 130 + <Pressable 131 + style={[ 132 + t.atoms.bg, 133 + a.flex_row, 134 + a.align_center, 135 + a.gap_md, 136 + a.border, 137 + t.atoms.border_contrast_high, 138 + a.rounded_md, 139 + a.px_lg, 140 + a.py_md, 141 + a.shadow_sm, 142 + { 143 + shadowRadius: 8, 144 + shadowOpacity: 0.1, 145 + shadowOffset: {width: 0, height: 2}, 146 + elevation: 8, 147 + }, 148 + ]} 149 + onPress={close} 150 + accessibilityLabel={_(msg`Tap to dismiss`)} 151 + accessibilityHint=""> 152 + <AnimatedCheck 153 + fill={t.palette.primary_500} 154 + ref={animatedCheckRef} 155 + /> 156 + <View> 157 + <Text style={[a.text_md, a.font_semibold]}>{title}</Text> 158 + {subtitle && ( 159 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 160 + {subtitle} 161 + </Text> 162 + )} 163 + </View> 164 + </Pressable> 165 + </Animated.View> 166 + </Portal> 167 + ) 168 + ) 169 + })
+92
src/components/anim/AnimatedCheck.tsx
··· 1 + import React from 'react' 2 + import Animated, { 3 + Easing, 4 + useAnimatedProps, 5 + useSharedValue, 6 + withDelay, 7 + withTiming, 8 + } from 'react-native-reanimated' 9 + import Svg, {Circle, Path} from 'react-native-svg' 10 + 11 + import {Props, useCommonSVGProps} from '#/components/icons/common' 12 + 13 + const AnimatedPath = Animated.createAnimatedComponent(Path) 14 + const AnimatedCircle = Animated.createAnimatedComponent(Circle) 15 + 16 + const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8' 17 + 18 + export interface AnimatedCheckRef { 19 + play(cb?: () => void): void 20 + } 21 + 22 + export interface AnimatedCheckProps extends Props { 23 + playOnMount?: boolean 24 + } 25 + 26 + export const AnimatedCheck = React.forwardRef< 27 + AnimatedCheckRef, 28 + AnimatedCheckProps 29 + >(function AnimatedCheck({playOnMount, ...props}, ref) { 30 + const {fill, size, style, ...rest} = useCommonSVGProps(props) 31 + const circleAnim = useSharedValue(0) 32 + const checkAnim = useSharedValue(0) 33 + 34 + const circleAnimatedProps = useAnimatedProps(() => ({ 35 + strokeDashoffset: 166 - circleAnim.value * 166, 36 + })) 37 + const checkAnimatedProps = useAnimatedProps(() => ({ 38 + strokeDashoffset: 48 - 48 * checkAnim.value, 39 + })) 40 + 41 + const play = React.useCallback( 42 + (cb?: () => void) => { 43 + circleAnim.value = 0 44 + checkAnim.value = 0 45 + 46 + circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear}) 47 + checkAnim.value = withDelay( 48 + 500, 49 + withTiming(1, {duration: 300, easing: Easing.linear}, cb), 50 + ) 51 + }, 52 + [circleAnim, checkAnim], 53 + ) 54 + 55 + React.useImperativeHandle(ref, () => ({ 56 + play, 57 + })) 58 + 59 + React.useEffect(() => { 60 + if (playOnMount) { 61 + play() 62 + } 63 + }, [play, playOnMount]) 64 + 65 + return ( 66 + <Svg 67 + fill="none" 68 + {...rest} 69 + viewBox="0 0 52 52" 70 + width={size} 71 + height={size} 72 + style={style}> 73 + <AnimatedCircle 74 + animatedProps={circleAnimatedProps} 75 + cx="26" 76 + cy="26" 77 + r="24" 78 + fill="none" 79 + stroke={fill} 80 + strokeWidth={4} 81 + strokeDasharray={166} 82 + /> 83 + <AnimatedPath 84 + animatedProps={checkAnimatedProps} 85 + stroke={fill} 86 + d={PATH} 87 + strokeWidth={4} 88 + strokeDasharray={48} 89 + /> 90 + </Svg> 91 + ) 92 + })
+1
src/lib/statsig/gates.ts
··· 7 7 | 'show_avi_follow_button' 8 8 | 'show_follow_back_label_v2' 9 9 | 'new_user_guided_tour' 10 + | 'new_user_progress_guide' 10 11 | 'suggested_feeds_interstitial' 11 12 | 'suggested_follows_interstitial'
+4
src/screens/Onboarding/StepFinished.tsx
··· 19 19 import {RQKEY as profileRQKey} from '#/state/queries/profile' 20 20 import {useAgent} from '#/state/session' 21 21 import {useOnboardingDispatch} from '#/state/shell' 22 + import {useProgressGuideControls} from '#/state/shell/progress-guide' 22 23 import {uploadBlob} from 'lib/api' 23 24 import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 24 25 import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' ··· 58 59 const setActiveStarterPack = useSetActiveStarterPack() 59 60 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 60 61 const setQueuedTour = useSetQueuedTour() 62 + const {startProgressGuide} = useProgressGuideControls() 61 63 62 64 const finishOnboarding = React.useCallback(async () => { 63 65 setSaving(true) ··· 185 187 setActiveStarterPack(undefined) 186 188 setHasCheckedForStarterPack(true) 187 189 setQueuedTour(TOURS.HOME) 190 + startProgressGuide('like-10-and-follow-7') 188 191 dispatch({type: 'finish'}) 189 192 onboardDispatch({type: 'finish'}) 190 193 track('OnboardingV2:StepFinished:End') ··· 218 221 setActiveStarterPack, 219 222 setHasCheckedForStarterPack, 220 223 setQueuedTour, 224 + startProgressGuide, 221 225 ]) 222 226 223 227 React.useEffect(() => {
+6
src/screens/StarterPack/StarterPackScreen.tsx
··· 18 18 import {cleanError} from '#/lib/strings/errors' 19 19 import {logger} from '#/logger' 20 20 import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' 21 + import { 22 + ProgressGuideAction, 23 + useProgressGuideControls, 24 + } from '#/state/shell/progress-guide' 21 25 import {batchedUpdates} from 'lib/batchedUpdates' 22 26 import {HITSLOP_20} from 'lib/constants' 23 27 import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted' ··· 287 291 const queryClient = useQueryClient() 288 292 const setActiveStarterPack = useSetActiveStarterPack() 289 293 const {requestSwitchToAccount} = useLoggedOutViewControls() 294 + const {captureAction} = useProgressGuideControls() 290 295 291 296 const [isProcessing, setIsProcessing] = React.useState(false) 292 297 ··· 351 356 starterPack: starterPack.uri, 352 357 count: dids.length, 353 358 }) 359 + captureAction(ProgressGuideAction.Follow, dids.length) 354 360 Toast.show(_(msg`All accounts have been followed!`)) 355 361 } catch (e) { 356 362 Toast.show(_(msg`An error occurred while trying to follow all`))
+4
src/state/queries/preferences/const.ts
··· 34 34 userAge: 13, // TODO(pwi) 35 35 interests: {tags: []}, 36 36 savedFeeds: [], 37 + bskyAppState: { 38 + queuedNudges: [], 39 + activeProgressGuide: undefined, 40 + }, 37 41 }
+47
src/state/queries/preferences/index.ts
··· 342 342 }, 343 343 }) 344 344 } 345 + 346 + export function useQueueNudgesMutation() { 347 + const queryClient = useQueryClient() 348 + const agent = useAgent() 349 + 350 + return useMutation({ 351 + mutationFn: async (nudges: string | string[]) => { 352 + await agent.bskyAppQueueNudges(nudges) 353 + // triggers a refetch 354 + await queryClient.invalidateQueries({ 355 + queryKey: preferencesQueryKey, 356 + }) 357 + }, 358 + }) 359 + } 360 + 361 + export function useDismissNudgesMutation() { 362 + const queryClient = useQueryClient() 363 + const agent = useAgent() 364 + 365 + return useMutation({ 366 + mutationFn: async (nudges: string | string[]) => { 367 + await agent.bskyAppDismissNudges(nudges) 368 + // triggers a refetch 369 + await queryClient.invalidateQueries({ 370 + queryKey: preferencesQueryKey, 371 + }) 372 + }, 373 + }) 374 + } 375 + 376 + export function useSetActiveProgressGuideMutation() { 377 + const queryClient = useQueryClient() 378 + const agent = useAgent() 379 + 380 + return useMutation({ 381 + mutationFn: async ( 382 + guide: AppBskyActorDefs.BskyAppProgressGuide | undefined, 383 + ) => { 384 + await agent.bskyAppSetActiveProgressGuide(guide) 385 + // triggers a refetch 386 + await queryClient.invalidateQueries({ 387 + queryKey: preferencesQueryKey, 388 + }) 389 + }, 390 + }) 391 + }
+7
src/state/queries/profile.ts
··· 25 25 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 26 26 import {updateProfileShadow} from '../cache/profile-shadow' 27 27 import {useAgent, useSession} from '../session' 28 + import { 29 + ProgressGuideAction, 30 + useProgressGuideControls, 31 + } from '../shell/progress-guide' 28 32 import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-converations' 29 33 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' 30 34 import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' ··· 274 278 const {currentAccount} = useSession() 275 279 const agent = useAgent() 276 280 const queryClient = useQueryClient() 281 + const {captureAction} = useProgressGuideControls() 282 + 277 283 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 278 284 mutationFn: async ({did}) => { 279 285 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined 280 286 if (currentAccount) { 281 287 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 282 288 } 289 + captureAction(ProgressGuideAction.Follow) 283 290 logEvent('profile:follow', { 284 291 logContext, 285 292 didBecomeMutual: profile.viewer
+185
src/state/shell/progress-guide.tsx
··· 1 + import React from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useGate} from '#/lib/statsig/statsig' 6 + import { 7 + ProgressGuideToast, 8 + ProgressGuideToastRef, 9 + } from '#/components/ProgressGuide/Toast' 10 + import { 11 + usePreferencesQuery, 12 + useSetActiveProgressGuideMutation, 13 + } from '../queries/preferences' 14 + 15 + export enum ProgressGuideAction { 16 + Like = 'like', 17 + Follow = 'follow', 18 + } 19 + 20 + type ProgressGuideName = 'like-10-and-follow-7' 21 + 22 + interface BaseProgressGuide { 23 + guide: string 24 + isComplete: boolean 25 + [key: string]: any 26 + } 27 + 28 + interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { 29 + numLikes: number 30 + numFollows: number 31 + } 32 + 33 + type ProgressGuide = Like10AndFollow7ProgressGuide | undefined 34 + 35 + const ProgressGuideContext = React.createContext<ProgressGuide>(undefined) 36 + 37 + const ProgressGuideControlContext = React.createContext<{ 38 + startProgressGuide(guide: ProgressGuideName): void 39 + endProgressGuide(): void 40 + captureAction(action: ProgressGuideAction, count?: number): void 41 + }>({ 42 + startProgressGuide: (_guide: ProgressGuideName) => {}, 43 + endProgressGuide: () => {}, 44 + captureAction: (_action: ProgressGuideAction, _count = 1) => {}, 45 + }) 46 + 47 + export function useProgressGuide(guide: ProgressGuideName) { 48 + const ctx = React.useContext(ProgressGuideContext) 49 + if (ctx?.guide === guide) { 50 + return ctx 51 + } 52 + return undefined 53 + } 54 + 55 + export function useProgressGuideControls() { 56 + return React.useContext(ProgressGuideControlContext) 57 + } 58 + 59 + export function Provider({children}: React.PropsWithChildren<{}>) { 60 + const {_} = useLingui() 61 + const {data: preferences} = usePreferencesQuery() 62 + const {mutateAsync, variables} = useSetActiveProgressGuideMutation() 63 + const gate = useGate() 64 + 65 + const activeProgressGuide = (variables || 66 + preferences?.bskyAppState?.activeProgressGuide) as ProgressGuide 67 + 68 + // ensure the unspecced attributes have the correct types 69 + if (activeProgressGuide?.guide === 'like-10-and-follow-7') { 70 + activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0 71 + activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0 72 + } 73 + 74 + const [localGuideState, setLocalGuideState] = 75 + React.useState<ProgressGuide>(undefined) 76 + 77 + if (activeProgressGuide && !localGuideState) { 78 + // hydrate from the server if needed 79 + setLocalGuideState(activeProgressGuide) 80 + } 81 + 82 + const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 83 + const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 84 + const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 85 + const guideCompleteToastRef = React.useRef<ProgressGuideToastRef | null>(null) 86 + 87 + const controls = React.useMemo(() => { 88 + return { 89 + startProgressGuide(guide: ProgressGuideName) { 90 + if (!gate('new_user_progress_guide')) { 91 + return 92 + } 93 + if (guide === 'like-10-and-follow-7') { 94 + const guideObj = { 95 + guide: 'like-10-and-follow-7', 96 + numLikes: 0, 97 + numFollows: 0, 98 + isComplete: false, 99 + } 100 + setLocalGuideState(guideObj) 101 + mutateAsync(guideObj) 102 + } 103 + }, 104 + 105 + endProgressGuide() { 106 + // update the persisted first 107 + mutateAsync(undefined).then(() => { 108 + // now clear local state, to avoid rehydrating from the server 109 + setLocalGuideState(undefined) 110 + }) 111 + }, 112 + 113 + captureAction(action: ProgressGuideAction, count = 1) { 114 + let guide = activeProgressGuide 115 + if (!guide || guide?.isComplete) { 116 + return 117 + } 118 + if (guide?.guide === 'like-10-and-follow-7') { 119 + if (action === ProgressGuideAction.Like) { 120 + guide = { 121 + ...guide, 122 + numLikes: (Number(guide.numLikes) || 0) + count, 123 + } 124 + if (guide.numLikes === 1) { 125 + firstLikeToastRef.current?.open() 126 + } 127 + if (guide.numLikes === 5) { 128 + fifthLikeToastRef.current?.open() 129 + } 130 + if (guide.numLikes === 10) { 131 + tenthLikeToastRef.current?.open() 132 + } 133 + } 134 + if (action === ProgressGuideAction.Follow) { 135 + guide = { 136 + ...guide, 137 + numFollows: (Number(guide.numFollows) || 0) + count, 138 + } 139 + } 140 + if (Number(guide.numLikes) >= 10 && Number(guide.numFollows) >= 7) { 141 + guide = { 142 + ...guide, 143 + isComplete: true, 144 + } 145 + } 146 + } 147 + 148 + setLocalGuideState(guide) 149 + mutateAsync(guide?.isComplete ? undefined : guide) 150 + }, 151 + } 152 + }, [activeProgressGuide, mutateAsync, gate, setLocalGuideState]) 153 + 154 + return ( 155 + <ProgressGuideContext.Provider value={localGuideState}> 156 + <ProgressGuideControlContext.Provider value={controls}> 157 + {children} 158 + {localGuideState?.guide === 'like-10-and-follow-7' && ( 159 + <> 160 + <ProgressGuideToast 161 + ref={firstLikeToastRef} 162 + title={_(msg`Your first like!`)} 163 + subtitle={_(msg`Like 10 posts to train the Discover feed`)} 164 + /> 165 + <ProgressGuideToast 166 + ref={fifthLikeToastRef} 167 + title={_(msg`Half way there!`)} 168 + subtitle={_(msg`Like 10 posts to train the Discover feed`)} 169 + /> 170 + <ProgressGuideToast 171 + ref={tenthLikeToastRef} 172 + title={_(msg`Task complete - 10 likes!`)} 173 + subtitle={_(msg`The Discover feed now knows what you like`)} 174 + /> 175 + <ProgressGuideToast 176 + ref={guideCompleteToastRef} 177 + title={_(msg`Algorithm training complete!`)} 178 + subtitle={_(msg`The Discover feed now knows what you like`)} 179 + /> 180 + </> 181 + )} 182 + </ProgressGuideControlContext.Provider> 183 + </ProgressGuideContext.Provider> 184 + ) 185 + }
+37 -9
src/view/com/posts/Feed.tsx
··· 34 34 import {useAnalytics} from 'lib/analytics/analytics' 35 35 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 36 36 import {useTheme} from 'lib/ThemeContext' 37 - import {SuggestedFeeds, SuggestedFollows} from '#/components/FeedInterstitials' 37 + import { 38 + ProgressGuide, 39 + SuggestedFeeds, 40 + SuggestedFollows, 41 + } from '#/components/FeedInterstitials' 38 42 import {List, ListRef} from '../util/List' 39 43 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 40 44 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' ··· 85 89 } 86 90 slot: number 87 91 } 92 + | { 93 + type: 'interstitialProgressGuide' 94 + key: string 95 + params: { 96 + variant: 'default' | string 97 + } 98 + slot: number 99 + } 88 100 89 101 const feedInterstitialType = 'interstitialFeeds' 90 102 const followInterstitialType = 'interstitialFollows' 103 + const progressGuideInterstitialType = 'interstitialProgressGuide' 91 104 const interstials: Record< 92 105 'following' | 'discover', 93 - (FeedItem & {type: 'interstitialFeeds' | 'interstitialFollows'})[] 106 + (FeedItem & { 107 + type: 108 + | 'interstitialFeeds' 109 + | 'interstitialFollows' 110 + | 'interstitialProgressGuide' 111 + })[] 94 112 > = { 95 113 following: [ 96 114 { ··· 112 130 ], 113 131 discover: [ 114 132 { 133 + type: progressGuideInterstitialType, 134 + params: { 135 + variant: 'default', 136 + }, 137 + key: progressGuideInterstitialType, 138 + slot: 0, 139 + }, 140 + { 115 141 type: feedInterstitialType, 116 142 params: { 117 143 variant: 'default', ··· 336 362 337 363 if (feedType) { 338 364 for (const interstitial of interstials[feedType]) { 339 - const feedInterstitialEnabled = 340 - interstitial.type === feedInterstitialType && 341 - gate('suggested_feeds_interstitial') 342 - const followInterstitialEnabled = 343 - interstitial.type === followInterstitialType && 344 - gate('suggested_follows_interstitial') 365 + const shouldShow = 366 + (interstitial.type === feedInterstitialType && 367 + gate('suggested_feeds_interstitial')) || 368 + (interstitial.type === followInterstitialType && 369 + gate('suggested_follows_interstitial')) || 370 + interstitial.type === progressGuideInterstitialType 345 371 346 - if (feedInterstitialEnabled || followInterstitialEnabled) { 372 + if (shouldShow) { 347 373 const variant = 'default' // replace with experiment variant 348 374 const int = { 349 375 ...interstitial, ··· 460 486 return <SuggestedFeeds /> 461 487 } else if (item.type === followInterstitialType) { 462 488 return <SuggestedFollows /> 489 + } else if (item.type === progressGuideInterstitialType) { 490 + return <ProgressGuide /> 463 491 } else if (item.type === 'slice') { 464 492 if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { 465 493 // HACK
+7
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 31 31 } from '#/state/queries/post' 32 32 import {useRequireAuth, useSession} from '#/state/session' 33 33 import {useComposerControls} from '#/state/shell/composer' 34 + import { 35 + ProgressGuideAction, 36 + useProgressGuideControls, 37 + } from '#/state/shell/progress-guide' 34 38 import {atoms as a, useTheme} from '#/alf' 35 39 import {useDialogControl} from '#/components/Dialog' 36 40 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' ··· 77 81 const requireAuth = useRequireAuth() 78 82 const loggedOutWarningPromptControl = useDialogControl() 79 83 const {sendInteraction} = useFeedFeedbackContext() 84 + const {captureAction} = useProgressGuideControls() 80 85 const playHaptic = useHaptics() 81 86 const gate = useGate() 82 87 ··· 103 108 event: 'app.bsky.feed.defs#interactionLike', 104 109 feedContext, 105 110 }) 111 + captureAction(ProgressGuideAction.Like) 106 112 await queueLike() 107 113 } else { 108 114 await queueUnlike() ··· 119 125 queueLike, 120 126 queueUnlike, 121 127 sendInteraction, 128 + captureAction, 122 129 feedContext, 123 130 ]) 124 131
+7 -3
src/view/shell/desktop/RightNav.tsx
··· 14 14 import {DesktopFeeds} from './Feeds' 15 15 import {DesktopSearch} from './Search' 16 16 import hairlineWidth = StyleSheet.hairlineWidth 17 + import {ProgressGuideList} from '#/components/ProgressGuide/List' 17 18 18 19 export function DesktopRightNav({routeName}: {routeName: string}) { 19 20 const pal = usePalette('default') ··· 39 40 <DesktopSearch /> 40 41 41 42 {hasSession && ( 42 - <View style={[pal.border, styles.desktopFeedsContainer]}> 43 - <DesktopFeeds /> 44 - </View> 43 + <> 44 + <ProgressGuideList style={[{marginTop: 22, marginBottom: 8}]} /> 45 + <View style={[pal.border, styles.desktopFeedsContainer]}> 46 + <DesktopFeeds /> 47 + </View> 48 + </> 45 49 )} 46 50 </> 47 51 )}
+5 -4
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.12.22": 38 - version "0.12.22" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22.tgz#1880a93a0caa4485cd8463bd1e10bf2424b9826c" 40 - integrity sha512-TIXSnf3qqyX40Ei/FkK4H24w+7s5rOc63TPwrGakRBOqIgSNBKOggei8I600fJ/AXB7HO6Vp9tBmDVOt2+021A== 37 + "@atproto/api@^0.12.23": 38 + version "0.12.23" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.23.tgz#b3409817d0b981a64f30d16e8257f0fe261338af" 40 + integrity sha512-fgQ30u+q9smX5g41eep7fISSkSAhRkX0inc81PZ82QwcHbFkC8ePaha/KP0CoTaPWKi7EsC89Z/8BEBCJo0oBA== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.3.0" 43 43 "@atproto/lexicon" "^0.4.0" 44 44 "@atproto/syntax" "^0.3.0" 45 45 "@atproto/xrpc" "^0.5.0" 46 + await-lock "^2.2.2" 46 47 multiformats "^9.9.0" 47 48 tlds "^1.234.0" 48 49