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 {Provider as ShellStateProvider} from '#/state/shell'
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 StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
@@ -119,10 +120,13 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
+4 -1
View File
@@ -34,6 +34,7 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
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 StarterPackProvider} from '#/state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast'
@@ -104,7 +105,9 @@ function InnerApp() {
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<Shell />
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
+47 -44
View File
@@ -1,58 +1,61 @@
import React from 'react'
import {View} from 'react-native'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
ProgressGuideAction,
ProgressGuideName,
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '../Button'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '../icons/Times'
import {Text} from '../Typography'
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() {
export function ProgressGuideList({style}: {style: StyleProp<ViewStyle>}) {
const t = useTheme()
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(() => {
const i = setInterval(() => {
setNumLikes(v => (v >= 10 ? 0 : v + 1))
setNumFollows(v => (v >= 7 ? 0 : v + 1))
}, 1000)
return () => {
clearInterval(i)
}
}, [setNumLikes])
const i = setInterval(() => captureAction(ProgressGuideAction.Like), 2e3)
return () => clearInterval(i)
}, [captureAction])
return (
<View style={[a.flex_col, a.gap_md]}>
<View style={[a.flex_row, a.align_center]}>
<Text
style={[t.atoms.text_contrast_medium, a.font_semibold, a.text_xs]}>
<Trans>GET STARTED</Trans>
</Text>
<Button
variant="ghost"
size="tiny"
color="secondary"
label={_(msg`Dismiss get started`)}>
<ButtonIcon icon={Times} />
</Button>
if (guide?.guide === ProgressGuideName.Like10Posts) {
return (
<View style={[a.flex_col, a.gap_md, style]}>
<View style={[a.flex_row, a.align_center]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.font_semibold,
a.text_xs,
{textTransform: 'uppercase'},
]}>
<Trans>Get started</Trans>
</Text>
<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>
<ProgressGuideTask
current={numLikes}
total={10}
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach Discover what you like.`)}
/>
<ProgressGuideTask
current={numFollows}
total={7}
title={_(msg`Follow 7 people`)}
/>
</View>
)
)
}
return null
}
+59 -40
View File
@@ -1,5 +1,5 @@
import React, {useImperativeHandle} from 'react'
import {Keyboard, Pressable, View} from 'react-native'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
Easing,
runOnJS,
@@ -11,6 +11,7 @@ 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'
@@ -39,6 +40,7 @@ export const ProgressGuideToast = React.forwardRef<
const opacity = useSharedValue(0)
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
const winDim = useWindowDimensions()
/**
* Methods
@@ -97,6 +99,20 @@ export const ProgressGuideToast = React.forwardRef<
[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,
@@ -105,45 +121,48 @@ export const ProgressGuideToast = React.forwardRef<
return (
isOpen && (
<Portal>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
onTouchMove={() => Keyboard.dismiss()}>
<Animated.View
style={[a.absolute, {top: 0, left: 10, right: 10}, 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,
]}
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>
</View>
<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>
)
)
+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 {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {useShellLayout} from '#/state/shell/shell-layout'
import {HITSLOP_10} from 'lib/constants'
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
@@ -34,11 +38,19 @@ export function HomeHeaderLayoutMobile({
const {headerHeight} = useShellLayout()
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession()
const {captureAction} = useProgressGuideControls()
const onPressAvi = React.useCallback(() => {
setDrawerOpen(true)
}, [setDrawerOpen])
React.useEffect(() => {
const i = setInterval(() => captureAction(ProgressGuideAction.Like), 1000)
return () => {
clearInterval(i)
}
}, [captureAction])
return (
<Animated.View
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 {DesktopSearch} from './Search'
import hairlineWidth = StyleSheet.hairlineWidth
import {ProgressGuideList} from '#/components/ProgressGuide/List'
export function DesktopRightNav({routeName}: {routeName: string}) {
const pal = usePalette('default')
@@ -39,9 +40,12 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
<DesktopSearch />
{hasSession && (
<View style={[pal.border, styles.desktopFeedsContainer]}>
<DesktopFeeds />
</View>
<>
<ProgressGuideList style={[{marginTop: 22, marginBottom: 8}]} />
<View style={[pal.border, styles.desktopFeedsContainer]}>
<DesktopFeeds />
</View>
</>
)}
</>
)}