Implement progress-guide controller

This commit is contained in:
Paul Frazee
2024-07-01 10:18:05 -07:00
parent 55a6a1c7af
commit 9513b574df
7 changed files with 274 additions and 92 deletions
+8 -4
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>
+4 -1
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>
+47 -44
View File
@@ -1,58 +1,61 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {
ProgressGuideAction,
ProgressGuideName,
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '../Button' import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '../icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '../Typography' import {Text} from '#/components/Typography'
import {ProgressGuideTask} from './Task' import {ProgressGuideTask} from './Task'
export function ProgressGuideList() { export function ProgressGuideList({style}: {style: StyleProp<ViewStyle>}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const guide = useProgressGuide(ProgressGuideName.Like10Posts)
const {captureAction, endProgressGuide} = useProgressGuideControls()
const [numLikes, setNumLikes] = React.useState<number>(0)
const [numFollows, setNumFollows] = React.useState<number>(0)
// DEBUG
React.useEffect(() => { React.useEffect(() => {
const i = setInterval(() => { const i = setInterval(() => captureAction(ProgressGuideAction.Like), 2e3)
setNumLikes(v => (v >= 10 ? 0 : v + 1)) return () => clearInterval(i)
setNumFollows(v => (v >= 7 ? 0 : v + 1)) }, [captureAction])
}, 1000)
return () => {
clearInterval(i)
}
}, [setNumLikes])
return ( if (guide?.guide === ProgressGuideName.Like10Posts) {
<View style={[a.flex_col, a.gap_md]}> return (
<View style={[a.flex_row, a.align_center]}> <View style={[a.flex_col, a.gap_md, style]}>
<Text <View style={[a.flex_row, a.align_center]}>
style={[t.atoms.text_contrast_medium, a.font_semibold, a.text_xs]}> <Text
<Trans>GET STARTED</Trans> style={[
</Text> t.atoms.text_contrast_medium,
<Button a.font_semibold,
variant="ghost" a.text_xs,
size="tiny" {textTransform: 'uppercase'},
color="secondary" ]}>
label={_(msg`Dismiss get started`)}> <Trans>Get started</Trans>
<ButtonIcon icon={Times} /> </Text>
</Button> <Button
variant="ghost"
size="tiny"
color="secondary"
label={_(msg`Dismiss get started`)}
onPress={endProgressGuide}>
<ButtonIcon icon={Times} />
</Button>
</View>
<ProgressGuideTask
current={guide.numLikes}
total={10}
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach Discover what you like.`)}
/>
</View> </View>
<ProgressGuideTask )
current={numLikes} }
total={10} return null
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach Discover what you like.`)}
/>
<ProgressGuideTask
current={numFollows}
total={7}
title={_(msg`Follow 7 people`)}
/>
</View>
)
} }
+59 -40
View File
@@ -1,5 +1,5 @@
import React, {useImperativeHandle} from 'react' import React, {useImperativeHandle} from 'react'
import {Keyboard, Pressable, View} from 'react-native' import {Pressable, useWindowDimensions, View} from 'react-native'
import Animated, { import Animated, {
Easing, Easing,
runOnJS, runOnJS,
@@ -11,6 +11,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck' import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
@@ -39,6 +40,7 @@ export const ProgressGuideToast = React.forwardRef<
const opacity = useSharedValue(0) const opacity = useSharedValue(0)
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null) const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>() const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
const winDim = useWindowDimensions()
/** /**
* Methods * Methods
@@ -97,6 +99,20 @@ export const ProgressGuideToast = React.forwardRef<
[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(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateY: translateY.value}], transform: [{translateY: translateY.value}],
opacity: opacity.value, opacity: opacity.value,
@@ -105,45 +121,48 @@ export const ProgressGuideToast = React.forwardRef<
return ( return (
isOpen && ( isOpen && (
<Portal> <Portal>
<View <Animated.View
// iOS style={[
accessibilityViewIsModal // @ts-ignore position: fixed is web only
// Android containerStyle,
importantForAccessibility="yes" animatedStyle,
style={[a.absolute, a.inset_0]} ]}>
onTouchMove={() => Keyboard.dismiss()}> <Pressable
<Animated.View style={[
style={[a.absolute, {top: 0, left: 10, right: 10}, animatedStyle]}> t.atoms.bg,
<Pressable a.flex_row,
style={[ a.align_center,
t.atoms.bg, a.gap_md,
a.flex_row, a.border,
a.align_center, t.atoms.border_contrast_high,
a.gap_md, a.rounded_md,
a.border, a.px_lg,
t.atoms.border_contrast_high, a.py_md,
a.rounded_md, a.shadow_sm,
a.px_lg, {
a.py_md, shadowRadius: 8,
]} shadowOpacity: 0.1,
onPress={close} shadowOffset: {width: 0, height: 2},
accessibilityLabel={_(msg`Tap to dismiss`)} elevation: 8,
accessibilityHint=""> },
<AnimatedCheck ]}
fill={t.palette.primary_500} onPress={close}
ref={animatedCheckRef} accessibilityLabel={_(msg`Tap to dismiss`)}
/> accessibilityHint="">
<View> <AnimatedCheck
<Text style={[a.text_md, a.font_semibold]}>{title}</Text> fill={t.palette.primary_500}
{subtitle && ( ref={animatedCheckRef}
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> />
{subtitle} <View>
</Text> <Text style={[a.text_md, a.font_semibold]}>{title}</Text>
)} {subtitle && (
</View> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
</Pressable> {subtitle}
</Animated.View> </Text>
</View> )}
</View>
</Pressable>
</Animated.View>
</Portal> </Portal>
) )
) )
+137
View File
@@ -0,0 +1,137 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
ProgressGuideToast,
ProgressGuideToastRef,
} from '#/components/ProgressGuide/Toast'
export enum ProgressGuideAction {
Like = 'like',
}
export enum ProgressGuideName {
Like10Posts = 'like-10-posts',
}
interface BaseProgressGuide {
guide: ProgressGuideName
isComplete: boolean
}
interface Like10PostsProgressGuide extends BaseProgressGuide {
guide: ProgressGuideName.Like10Posts
numLikes: number
}
type ProgressGuide = Like10PostsProgressGuide | undefined
const ProgressGuideContext = React.createContext<ProgressGuide>(undefined)
const ProgressGuideControlContext = React.createContext<{
startProgressGuide(guide: ProgressGuideName): void
endProgressGuide(): void
captureAction(action: ProgressGuideAction): void
}>({
startProgressGuide: (_guide: ProgressGuideName) => {},
endProgressGuide: () => {},
captureAction: (_action: ProgressGuideAction) => {},
})
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 [activeProgressGuide, setActiveProgressGuide] =
React.useState<ProgressGuide>({
guide: ProgressGuideName.Like10Posts,
numLikes: 0,
isComplete: false,
})
const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
const controls = React.useMemo(() => {
return {
startProgressGuide(guide: ProgressGuideName) {
if (guide === ProgressGuideName.Like10Posts) {
setActiveProgressGuide({
guide: ProgressGuideName.Like10Posts,
numLikes: 0,
isComplete: false,
})
}
},
endProgressGuide() {
setActiveProgressGuide(undefined)
},
captureAction(action: ProgressGuideAction) {
let guide = activeProgressGuide
if (guide?.isComplete) {
return
}
if (guide?.guide === ProgressGuideName.Like10Posts) {
if (action === ProgressGuideAction.Like) {
guide = {
...guide,
numLikes: (guide.numLikes || 0) + 1,
}
if (guide.numLikes === 1) {
firstLikeToastRef.current?.open()
}
if (guide.numLikes === 5) {
fifthLikeToastRef.current?.open()
}
if (guide.numLikes === 10) {
tenthLikeToastRef.current?.open()
guide = {
...guide,
isComplete: true,
}
}
}
}
setActiveProgressGuide(guide)
},
}
}, [activeProgressGuide, setActiveProgressGuide])
return (
<ProgressGuideContext.Provider value={activeProgressGuide}>
<ProgressGuideControlContext.Provider value={controls}>
{children}
<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`)}
/>
</ProgressGuideControlContext.Provider>
</ProgressGuideContext.Provider>
)
}
@@ -6,6 +6,10 @@ import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {HITSLOP_10} from 'lib/constants' import {HITSLOP_10} from 'lib/constants'
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform' import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
@@ -34,11 +38,19 @@ export function HomeHeaderLayoutMobile({
const {headerHeight} = useShellLayout() const {headerHeight} = useShellLayout()
const headerMinimalShellTransform = useMinimalShellHeaderTransform() const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession() const {hasSession} = useSession()
const {captureAction} = useProgressGuideControls()
const onPressAvi = React.useCallback(() => { const onPressAvi = React.useCallback(() => {
setDrawerOpen(true) setDrawerOpen(true)
}, [setDrawerOpen]) }, [setDrawerOpen])
React.useEffect(() => {
const i = setInterval(() => captureAction(ProgressGuideAction.Like), 1000)
return () => {
clearInterval(i)
}
}, [captureAction])
return ( return (
<Animated.View <Animated.View
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
+7 -3
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>
</>
)} )}
</> </>
)} )}