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

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