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`)}
+ />
+