about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-07-02 21:25:19 -0700
committerGitHub <noreply@github.com>2024-07-02 21:25:19 -0700
commita3d4fb652b888ba81aecbf0e81a954968ea65d39 (patch)
treee78df8bf670baee080fa77b198db30058a012589
parent6694a33603544511441474819216d51482d19827 (diff)
downloadvoidsky-a3d4fb652b888ba81aecbf0e81a954968ea65d39.tar.zst
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>
-rw-r--r--package.json1
-rw-r--r--src/App.native.tsx11
-rw-r--r--src/App.web.tsx5
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/screens/Onboarding/StepFinished.tsx4
-rw-r--r--src/tours/Debug.tsx18
-rw-r--r--src/tours/HomeTour.tsx93
-rw-r--r--src/tours/Tooltip.tsx168
-rw-r--r--src/tours/index.tsx62
-rw-r--r--src/tours/positioning.ts23
-rw-r--r--src/tours/positioning.web.ts27
-rw-r--r--src/view/com/home/HomeHeaderLayoutMobile.tsx8
-rw-r--r--src/view/screens/Home.tsx10
-rw-r--r--src/view/screens/Settings/index.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx25
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx11
-rw-r--r--src/view/shell/desktop/LeftNav.tsx22
-rw-r--r--yarn.lock88
18 files changed, 541 insertions, 39 deletions
diff --git a/package.json b/package.json
index 61d3eea77..852565513 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 639276a12..18af74409 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() {
                               <UnreadNotifsProvider>
                                 <BackgroundNotificationPreferencesProvider>
                                   <MutedThreadsProvider>
-                                    <GestureHandlerRootView style={s.h100pct}>
-                                      <TestCtrls />
-                                      <Shell />
-                                    </GestureHandlerRootView>
+                                    <TourProvider>
+                                      <GestureHandlerRootView style={s.h100pct}>
+                                        <TestCtrls />
+                                        <Shell />
+                                      </GestureHandlerRootView>
+                                    </TourProvider>
                                   </MutedThreadsProvider>
                                 </BackgroundNotificationPreferencesProvider>
                               </UnreadNotifsProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 31a59d97d..f45806e4d 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() {
                               <BackgroundNotificationPreferencesProvider>
                                 <MutedThreadsProvider>
                                   <SafeAreaProvider>
-                                    <Shell />
+                                    <TourProvider>
+                                      <Shell />
+                                    </TourProvider>
                                   </SafeAreaProvider>
                                 </MutedThreadsProvider>
                               </BackgroundNotificationPreferencesProvider>
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 0b253b278..e4991ad38 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 9613ce660..1cb925c1f 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 000000000..ba643a802
--- /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 (
+    <Button
+      label="Start tour"
+      onPress={() => {
+        start()
+      }}>
+      {() => <Text>t</Text>}
+    </Button>
+  )
+}
diff --git a/src/tours/HomeTour.tsx b/src/tours/HomeTour.tsx
new file mode 100644
index 000000000..d938fe0e0
--- /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 (
+    <>
+      <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>
+  )
+}
diff --git a/src/tours/Tooltip.tsx b/src/tours/Tooltip.tsx
new file mode 100644
index 000000000..e7727763b
--- /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<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>
+  )
+}
diff --git a/src/tours/index.tsx b/src/tours/index.tsx
new file mode 100644
index 000000000..8d4ca26b8
--- /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<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)
+}
diff --git a/src/tours/positioning.ts b/src/tours/positioning.ts
new file mode 100644
index 000000000..03d61f53f
--- /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 000000000..fd0f7aa71
--- /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 8cf0452ce..ed353cf16 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 && (
-            <Link to="/sys/debug">
-              <ColorPalette size="md" />
-            </Link>
+            <>
+              <Link to="/sys/debug">
+                <ColorPalette size="md" />
+              </Link>
+            </>
           )}
           {hasSession && (
             <Link
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 98694219a..f7cecd872 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -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(
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index d075cc696..1d8199b00 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 b5ad92b4c..80886b320 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) {
             <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"
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 21c253ee0..c89d2a63c 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 (
-                <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>
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index ca8073f57..49fb7fc99 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={<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}
diff --git a/yarn.lock b/yarn.lock
index eeaea01c3..d31fa2f63 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10172,6 +10172,11 @@ commander@11.0.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
   integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
 
+commander@2, commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
 commander@2.20.0:
   version "2.20.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
@@ -10182,11 +10187,6 @@ commander@^10.0.0, commander@^10.0.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
   integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
 
-commander@^2.20.0:
-  version "2.20.3"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
-  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
-
 commander@^4.0.0:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@@ -10678,6 +10678,16 @@ csstype@^3.0.2:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
   integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
 
+d3-array@^1.2.0:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
+  integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
+
+d3-polygon@^1.0.3:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
+  integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
+
 dag-map@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-1.0.2.tgz#e8379f041000ed561fc515475c1ed2c85eece8d7"
@@ -11159,6 +11169,11 @@ duplexer@^0.1.2:
   resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
   integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
 
+earcut@^2.1.1:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a"
+  integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==
+
 eastasianwidth@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@@ -12712,6 +12727,18 @@ flow-parser@0.*:
   resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.215.0.tgz#9b153fa27ab238bcc0bb1ff73b63bdb15d3f277d"
   integrity sha512-8bjwzy8vi+fNDy8YoTBNtQUSZa53i7UWJJTunJojOtjab9cMNhOCwohionuMgDQUU0y21QTTtPOX6OQEOQT72A==
 
+flubber@~0.4.2:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
+  integrity sha512-79RkJe3rA4nvRCVc2uXjj7U/BAUq84TS3KHn6c0Hr9K64vhj83ZNLUziNx4pJoBumSPhOl5VjH+Z0uhi+eE8Uw==
+  dependencies:
+    d3-array "^1.2.0"
+    d3-polygon "^1.0.3"
+    earcut "^2.1.1"
+    svg-path-properties "^0.2.1"
+    svgpath "^2.2.1"
+    topojson-client "^3.0.0"
+
 follow-redirects@^1.0.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
   version "1.15.2"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
@@ -13328,6 +13355,13 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
   dependencies:
     react-is "^16.7.0"
 
+hoist-non-react-statics@~3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364"
+  integrity sha512-1kXwPsOi0OGQIZNVMPvgWJ9tSnGMiMfJdihqEzrPEXlHOBh9AAHXX/QYmAJTXztnz/K+PQ8ryCb4eGaN6HlGbQ==
+  dependencies:
+    react-is "^16.3.2"
+
 hoopy@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
@@ -15806,6 +15840,11 @@ lodash.chunk@^4.2.0:
   resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
   integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==
 
+lodash.clamp@~4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/lodash.clamp/-/lodash.clamp-4.0.3.tgz#5c24bedeeeef0753560dc2b4cb4671f90a6ddfaa"
+  integrity sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==
+
 lodash.debounce@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@@ -16090,6 +16129,11 @@ memfs@^3.1.2, memfs@^3.4.3:
   dependencies:
     fs-monkey "^1.0.4"
 
+memoize-one@5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
+  integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
+
 memoize-one@^5.0.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
@@ -16517,6 +16561,11 @@ minizlib@^2.1.1:
     minipass "^3.0.0"
     yallist "^4.0.0"
 
+mitt@~1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8"
+  integrity sha512-mUDCnVNsAi+eD6qA0HkRkwYczbLHJ49z17BGe2PYRhZL4wpZUFZGJHU7/5tmvohoma+Hdn0Vh/oJTiPEmgSruA==
+
 mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
   version "0.5.3"
   resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
@@ -18780,7 +18829,7 @@ react-freeze@^1.0.0:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
 
-react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4:
+react-is@^16.13.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.4:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -19585,6 +19634,16 @@ rn-fetch-blob@^0.12.0:
     base-64 "0.1.0"
     glob "7.0.6"
 
+rn-tourguide@bluesky-social/rn-tourguide:
+  version "3.3.0"
+  resolved "https://codeload.github.com/bluesky-social/rn-tourguide/tar.gz/a14bb85536b317b94d82801900df4cf57f81aef7"
+  dependencies:
+    flubber "~0.4.2"
+    hoist-non-react-statics "~3.0.1"
+    lodash.clamp "~4.0.3"
+    memoize-one "5.1.1"
+    mitt "~1.1.3"
+
 roarr@^7.0.4:
   version "7.15.1"
   resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.1.tgz#e4d93105c37b5ea7dd1200d96a3500f757ddc39f"
@@ -20711,6 +20770,11 @@ svg-parser@^2.0.2:
   resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
   integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
 
+svg-path-properties@^0.2.1:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/svg-path-properties/-/svg-path-properties-0.2.2.tgz#b073d81be7292eae0e233ab8a83f58dc27113296"
+  integrity sha512-GmrB+b6woz6CCdQe6w1GHs/1lt25l7SR5hmhF8jRdarpv/OgjLyuQygLu1makJapixeb1aQhP/Oa1iKi93o/aQ==
+
 svgo@^1.2.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
@@ -20743,6 +20807,11 @@ svgo@^2.7.0:
     picocolors "^1.0.0"
     stable "^0.1.8"
 
+svgpath@^2.2.1:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d"
+  integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==
+
 symbol-tree@^3.2.4:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -21054,6 +21123,13 @@ token-types@^4.1.1:
     "@tokenizer/token" "^0.3.0"
     ieee754 "^1.2.1"
 
+topojson-client@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99"
+  integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==
+  dependencies:
+    commander "2"
+
 totalist@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"