Guided tour for new users (#4690)

* Add home guided tour (WIP)

* Add web handling of the tour

* Switch to our fork of rn-tourguide

* Bump guided-tour

* Fix alignment on android

* Implement home page tour trigger after account creation

* Add new_user_guided_tour gate

* Add a title line to the tour tooltips

* A11y improvements: proper labels, focus capture, scroll capture

* Silence type error

* Native a11y

* Use FocusScope

* Switch to useWebBodyScrollLock()

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2024-07-02 21:25:19 -07:00 committed by GitHub
parent 6694a33603
commit a3d4fb652b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 541 additions and 39 deletions

View file

@ -55,6 +55,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@ -117,10 +118,12 @@ function InnerApp() {
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
<TourProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>

View file

@ -43,6 +43,7 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@ -102,7 +103,9 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<Shell />
<TourProvider>
<Shell />
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>

View file

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

View file

@ -42,6 +42,7 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {TOURS, useSetQueuedTour} from '#/tours'
export function StepFinished() {
const {_} = useLingui()
@ -56,6 +57,7 @@ export function StepFinished() {
const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const setQueuedTour = useSetQueuedTour()
const finishOnboarding = React.useCallback(async () => {
setSaving(true)
@ -182,6 +184,7 @@ export function StepFinished() {
setSaving(false)
setActiveStarterPack(undefined)
setHasCheckedForStarterPack(true)
setQueuedTour(TOURS.HOME)
dispatch({type: 'finish'})
onboardDispatch({type: 'finish'})
track('OnboardingV2:StepFinished:End')
@ -214,6 +217,7 @@ export function StepFinished() {
requestNotificationsPermission,
setActiveStarterPack,
setHasCheckedForStarterPack,
setQueuedTour,
])
React.useEffect(() => {

18
src/tours/Debug.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react'
import {useTourGuideController} from 'rn-tourguide'
import {Button} from '#/components/Button'
import {Text} from '#/components/Typography'
export function TourDebugButton() {
const {start} = useTourGuideController('home')
return (
<Button
label="Start tour"
onPress={() => {
start()
}}>
{() => <Text>t</Text>}
</Button>
)
}

93
src/tours/HomeTour.tsx Normal file
View file

@ -0,0 +1,93 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
IStep,
TourGuideZone,
TourGuideZoneByPosition,
useTourGuideController,
} from 'rn-tourguide'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {isWeb} from '#/platform/detection'
import {useSetSelectedFeed} from '#/state/shell/selected-feed'
import {TOURS} from '.'
import {useHeaderPosition} from './positioning'
export function HomeTour() {
const {_} = useLingui()
const {tourKey, eventEmitter} = useTourGuideController(TOURS.HOME)
const setSelectedFeed = useSetSelectedFeed()
const headerPosition = useHeaderPosition()
React.useEffect(() => {
const handleOnStepChange = (step?: IStep) => {
if (step?.order === 2) {
setSelectedFeed('following')
} else if (step?.order === 3) {
setSelectedFeed(`feedgen|${DISCOVER_FEED_URI}`)
}
}
eventEmitter?.on('stepChange', handleOnStepChange)
return () => {
eventEmitter?.off('stepChange', handleOnStepChange)
}
}, [eventEmitter, setSelectedFeed])
return (
<>
<TourGuideZoneByPosition
isTourGuide
tourKey={tourKey}
zone={1}
top={headerPosition.top}
left={headerPosition.left}
width={headerPosition.width}
height={headerPosition.height}
borderRadiusObject={headerPosition.borderRadiusObject}
text={_(msg`Switch between feeds to control your experience.`)}
/>
<TourGuideZoneByPosition
isTourGuide
tourKey={tourKey}
zone={2}
top={headerPosition.top}
left={headerPosition.left}
width={headerPosition.width}
height={headerPosition.height}
borderRadiusObject={headerPosition.borderRadiusObject}
text={_(msg`Following shows the latest posts from people you follow.`)}
/>
<TourGuideZoneByPosition
isTourGuide
tourKey={tourKey}
zone={3}
top={headerPosition.top}
left={headerPosition.left}
width={headerPosition.width}
height={headerPosition.height}
borderRadiusObject={headerPosition.borderRadiusObject}
text={_(msg`Discover learns which posts you like as you browse.`)}
/>
</>
)
}
export function HomeTourExploreWrapper({
children,
}: React.PropsWithChildren<{}>) {
const {_} = useLingui()
const {tourKey} = useTourGuideController(TOURS.HOME)
return (
<TourGuideZone
tourKey={tourKey}
zone={4}
tooltipBottomOffset={50}
shape={isWeb ? 'rectangle' : 'circle'}
text={_(
msg`Find more feeds and accounts to follow in the Explore page.`,
)}>
{children}
</TourGuideZone>
)
}

168
src/tours/Tooltip.tsx Normal file
View file

@ -0,0 +1,168 @@
import * as React from 'react'
import {
AccessibilityInfo,
findNodeHandle,
Pressable,
Text as RNText,
View,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FocusScope} from '@tamagui/focus-scope'
import {IStep, Labels} from 'rn-tourguide'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useA11y} from '#/state/a11y'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {leading, Text} from '#/components/Typography'
const stopPropagation = (e: any) => e.stopPropagation()
export interface TooltipComponentProps {
isFirstStep?: boolean
isLastStep?: boolean
currentStep: IStep
labels?: Labels
handleNext?: () => void
handlePrev?: () => void
handleStop?: () => void
}
export function TooltipComponent({
isLastStep,
handleNext,
handleStop,
currentStep,
labels,
}: TooltipComponentProps) {
const t = useTheme()
const {_} = useLingui()
const btnRef = React.useRef<View>(null)
const textRef = React.useRef<RNText>(null)
const {screenReaderEnabled} = useA11y()
useWebBodyScrollLock(true)
const focusTextNode = () => {
const node = textRef.current ? findNodeHandle(textRef.current) : undefined
if (node) {
AccessibilityInfo.setAccessibilityFocus(node)
}
}
// handle initial focus immediately on mount
React.useLayoutEffect(() => {
focusTextNode()
}, [])
// handle focus between steps
const innerHandleNext = () => {
handleNext?.()
setTimeout(() => focusTextNode(), 200)
}
return (
<FocusScope loop enabled trapped>
<View
role="alert"
aria-role="alert"
aria-label={_(msg`A help tooltip`)}
accessibilityLiveRegion="polite"
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
// @ts-ignore web only
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
style={[
t.atoms.bg,
a.px_lg,
a.py_lg,
a.flex_col,
a.gap_md,
a.rounded_sm,
a.shadow_md,
{maxWidth: 300},
]}>
{screenReaderEnabled && (
<Pressable
style={[
a.absolute,
a.inset_0,
a.z_10,
{height: 10, bottom: 'auto'},
]}
accessibilityLabel={_(
msg`Start of onboarding tour window. Do not move backward. Instead, go forward for more options, or press to skip.`,
)}
accessibilityHint={undefined}
onPress={handleStop}
/>
)}
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Logo width={16} style={{position: 'relative', top: 0}} />
<Text
accessible={false}
style={[a.text_sm, a.font_semibold, t.atoms.text_contrast_medium]}>
<Trans>Quick tip</Trans>
</Text>
</View>
<RNText
ref={textRef}
testID="stepDescription"
accessibilityLabel={_(
msg`Onboarding tour step ${currentStep.name}: ${currentStep.text}`,
)}
accessibilityHint={undefined}
style={[
a.text_md,
t.atoms.text,
a.pb_sm,
{
lineHeight: leading(a.text_md, a.leading_snug),
},
]}>
{currentStep.text}
</RNText>
{!isLastStep ? (
<Button
ref={btnRef}
variant="gradient"
color="gradient_sky"
size="medium"
onPress={innerHandleNext}
label={labels?.next || _(msg`Go to the next step of the tour`)}>
<ButtonText>{labels?.next || _(msg`Next`)}</ButtonText>
</Button>
) : (
<Button
variant="gradient"
color="gradient_sky"
size="medium"
onPress={handleStop}
label={
labels?.finish ||
_(msg`Finish tour and begin using the application`)
}>
<ButtonText>{labels?.finish || _(msg`Let's go!`)}</ButtonText>
</Button>
)}
{screenReaderEnabled && (
<Pressable
style={[a.absolute, a.inset_0, a.z_10, {height: 10, top: 'auto'}]}
accessibilityLabel={_(
msg`End of onboarding tour window. Do not move forward. Instead, go backward for more options, or press to skip.`,
)}
accessibilityHint={undefined}
onPress={handleStop}
/>
)}
</View>
</FocusScope>
)
}

62
src/tours/index.tsx Normal file
View file

@ -0,0 +1,62 @@
import React from 'react'
import {InteractionManager} from 'react-native'
import {TourGuideProvider, useTourGuideController} from 'rn-tourguide'
import {useGate} from '#/lib/statsig/statsig'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {HomeTour} from './HomeTour'
import {TooltipComponent} from './Tooltip'
export enum TOURS {
HOME = 'home',
}
type StateContext = TOURS | null
type SetContext = (v: TOURS | null) => void
const stateContext = React.createContext<StateContext>(null)
const setContext = React.createContext<SetContext>((_: TOURS | null) => {})
export function Provider({children}: React.PropsWithChildren<{}>) {
const theme = useColorModeTheme()
const [state, setState] = React.useState<TOURS | null>(() => null)
return (
<TourGuideProvider
androidStatusBarVisible
tooltipComponent={TooltipComponent}
backdropColor={
theme === 'light' ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.8)'
}
preventOutsideInteraction>
<stateContext.Provider value={state}>
<setContext.Provider value={setState}>
<HomeTour />
{children}
</setContext.Provider>
</stateContext.Provider>
</TourGuideProvider>
)
}
export function useTriggerTourIfQueued(tour: TOURS) {
const {start} = useTourGuideController(tour)
const setQueuedTour = React.useContext(setContext)
const queuedTour = React.useContext(stateContext)
const gate = useGate()
return React.useCallback(() => {
if (queuedTour === tour) {
setQueuedTour(null)
InteractionManager.runAfterInteractions(() => {
if (gate('new_user_guided_tour')) {
start()
}
})
}
}, [tour, queuedTour, setQueuedTour, start, gate])
}
export function useSetQueuedTour() {
return React.useContext(setContext)
}

23
src/tours/positioning.ts Normal file
View file

@ -0,0 +1,23 @@
import {useWindowDimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useShellLayout} from '#/state/shell/shell-layout'
export function useHeaderPosition() {
const {headerHeight} = useShellLayout()
const {width} = useWindowDimensions()
const insets = useSafeAreaInsets()
return {
top: insets.top,
left: 10,
width: width - 20,
height: headerHeight.value,
borderRadiusObject: {
topLeft: 4,
topRight: 4,
bottomLeft: 4,
bottomRight: 4,
},
}
}

View file

@ -0,0 +1,27 @@
import {useWindowDimensions} from 'react-native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useShellLayout} from '#/state/shell/shell-layout'
export function useHeaderPosition() {
const {headerHeight} = useShellLayout()
const winDim = useWindowDimensions()
const {isMobile} = useWebMediaQueries()
let left = 0
let width = winDim.width
if (width > 590 && !isMobile) {
left = winDim.width / 2 - 295
width = 590
}
let offset = isMobile ? 45 : 0
return {
top: headerHeight.value - offset,
left,
width,
height: 45,
borderRadiusObject: undefined,
}
}

View file

@ -72,9 +72,11 @@ export function HomeHeaderLayoutMobile({
{width: 100},
]}>
{IS_DEV && (
<Link to="/sys/debug">
<ColorPalette size="md" />
</Link>
<>
<Link to="/sys/debug">
<ColorPalette size="md" />
</Link>
</>
)}
{hasSession && (
<Link

View file

@ -28,6 +28,7 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
import {TOURS, useTriggerTourIfQueued} from '#/tours'
import {HomeHeader} from '../com/home/HomeHeader'
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
@ -86,6 +87,7 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@ -113,10 +115,16 @@ function HomeScreenReady({
React.useCallback(() => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(selectedIndex > 0)
triggerTourIfQueued()
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
}, [
setDrawerSwipeDisabled,
selectedIndex,
setMinimalShellMode,
triggerTourIfQueued,
]),
)
useFocusEffect(

View file

@ -252,9 +252,10 @@ export function SettingsScreen({}: Props) {
}, [clearPreferences])
const onPressResetOnboarding = React.useCallback(async () => {
navigation.navigate('Home')
onboardingDispatch({type: 'start'})
Toast.show(_(msg`Onboarding reset`))
}, [onboardingDispatch, _])
}, [navigation, onboardingDispatch, _])
const onPressBuildInfo = React.useCallback(() => {
setStringAsync(

View file

@ -45,6 +45,7 @@ import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
} from '#/components/icons/Message'
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
import {styles} from './BottomBarStyles'
type TabOptions =
@ -162,17 +163,19 @@ export function BottomBar({navigation}: BottomTabBarProps) {
<Btn
testID="bottomBarSearchBtn"
icon={
isAtSearch ? (
<MagnifyingGlassFilled
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
) : (
<MagnifyingGlass
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
)
<HomeTourExploreWrapper>
{isAtSearch ? (
<MagnifyingGlassFilled
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
) : (
<MagnifyingGlass
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
)}
</HomeTourExploreWrapper>
}
onPress={onPressSearch}
accessibilityRole="search"

View file

@ -41,6 +41,7 @@ import {
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
} from '#/components/icons/UserCircle'
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
import {styles} from './BottomBarStyles'
export function BottomBarWeb() {
@ -94,10 +95,12 @@ export function BottomBarWeb() {
{({isActive}) => {
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
return (
<Icon
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
<HomeTourExploreWrapper>
<Icon
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
</HomeTourExploreWrapper>
)
}}
</NavItem>

View file

@ -63,6 +63,7 @@ import {
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
} from '#/components/icons/UserCircle'
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
import {router} from '../../../routes'
const NAV_ICON_WIDTH = 28
@ -340,14 +341,19 @@ export function DesktopLeftNav() {
iconFilled={<HomeFilled width={NAV_ICON_WIDTH} style={pal.text} />}
label={_(msg`Home`)}
/>
<NavItem
href="/search"
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
iconFilled={
<MagnifyingGlassFilled style={pal.text} width={NAV_ICON_WIDTH} />
}
label={_(msg`Search`)}
/>
<HomeTourExploreWrapper>
<NavItem
href="/search"
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
iconFilled={
<MagnifyingGlassFilled
style={pal.text}
width={NAV_ICON_WIDTH}
/>
}
label={_(msg`Search`)}
/>
</HomeTourExploreWrapper>
<NavItem
href="/notifications"
count={numUnreadNotifications}