diff --git a/package.json b/package.json index 61d3eea7..85256551 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "react-responsive": "^9.0.2", "react-textarea-autosize": "^8.5.3", "rn-fetch-blob": "^0.12.0", + "rn-tourguide": "bluesky-social/rn-tourguide", "sentry-expo": "~7.0.1", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", diff --git a/src/App.native.tsx b/src/App.native.tsx index 639276a1..18af7440 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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() { - - - - + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 31a59d97..f45806e4 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - + + + diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 0b253b27..e4991ad3 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -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' diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 9613ce66..1cb925c1 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -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(() => { diff --git a/src/tours/Debug.tsx b/src/tours/Debug.tsx new file mode 100644 index 00000000..ba643a80 --- /dev/null +++ b/src/tours/Debug.tsx @@ -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 ( + + ) +} diff --git a/src/tours/HomeTour.tsx b/src/tours/HomeTour.tsx new file mode 100644 index 00000000..d938fe0e --- /dev/null +++ b/src/tours/HomeTour.tsx @@ -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 ( + <> + + + + + ) +} + +export function HomeTourExploreWrapper({ + children, +}: React.PropsWithChildren<{}>) { + const {_} = useLingui() + const {tourKey} = useTourGuideController(TOURS.HOME) + return ( + + {children} + + ) +} diff --git a/src/tours/Tooltip.tsx b/src/tours/Tooltip.tsx new file mode 100644 index 00000000..e7727763 --- /dev/null +++ b/src/tours/Tooltip.tsx @@ -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(null) + const textRef = React.useRef(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 ( + + 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 && ( + + )} + + + + + Quick tip + + + + {currentStep.text} + + {!isLastStep ? ( + + ) : ( + + )} + + {screenReaderEnabled && ( + + )} + + + ) +} diff --git a/src/tours/index.tsx b/src/tours/index.tsx new file mode 100644 index 00000000..8d4ca26b --- /dev/null +++ b/src/tours/index.tsx @@ -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(null) +const setContext = React.createContext((_: TOURS | null) => {}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const theme = useColorModeTheme() + const [state, setState] = React.useState(() => null) + + return ( + + + + + {children} + + + + ) +} + +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) +} diff --git a/src/tours/positioning.ts b/src/tours/positioning.ts new file mode 100644 index 00000000..03d61f53 --- /dev/null +++ b/src/tours/positioning.ts @@ -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, + }, + } +} diff --git a/src/tours/positioning.web.ts b/src/tours/positioning.web.ts new file mode 100644 index 00000000..fd0f7aa7 --- /dev/null +++ b/src/tours/positioning.web.ts @@ -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, + } +} diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index 8cf0452c..ed353cf1 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -72,9 +72,11 @@ export function HomeHeaderLayoutMobile({ {width: 100}, ]}> {IS_DEV && ( - - - + <> + + + + )} {hasSession && ( @@ -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( diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index d075cc69..1d8199b0 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -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( diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index b5ad92b4..80886b32 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -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) { - ) : ( - - ) + + {isAtSearch ? ( + + ) : ( + + )} + } onPress={onPressSearch} accessibilityRole="search" diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 21c253ee..c89d2a63 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -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 ( - + + + ) }} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index ca8073f5..49fb7fc9 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -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={} label={_(msg`Home`)} /> - } - iconFilled={ - - } - label={_(msg`Search`)} - /> + + } + iconFilled={ + + } + label={_(msg`Search`)} + /> +