about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx1
-rw-r--r--src/lib/analytics/types.ts2
-rw-r--r--src/lib/constants.ts107
-rw-r--r--src/lib/hooks/useWebMediaQueries.tsx10
-rw-r--r--src/state/models/discovery/onboarding.ts94
-rw-r--r--src/state/models/feeds/custom-feed.ts13
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/models/ui/create-account.ts4
-rw-r--r--src/state/models/ui/shell.ts7
-rw-r--r--src/view/com/auth/Onboarding.tsx34
-rw-r--r--src/view/com/auth/onboarding/Onboarding.tsx66
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx176
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx142
-rw-r--r--src/view/com/auth/onboarding/Welcome.tsx102
-rw-r--r--src/view/com/auth/onboarding/WelcomeDesktop.tsx123
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx123
-rw-r--r--src/view/com/auth/withAuthRequired.tsx4
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/OnboardingModal.tsx8
-rw-r--r--src/view/com/util/ViewHeader.tsx78
-rw-r--r--src/view/com/util/layouts/Breakpoints.tsx8
-rw-r--r--src/view/com/util/layouts/Breakpoints.web.tsx20
-rw-r--r--src/view/com/util/layouts/TitleColumnLayout.tsx69
-rw-r--r--src/view/com/util/layouts/withBreakpoints.tsx21
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Settings.tsx15
-rw-r--r--src/view/shell/index.web.tsx8
29 files changed, 1034 insertions, 218 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index df601d0cd..2422491e2 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -260,6 +260,7 @@ function TabsNavigator() {
 
 function HomeTabNavigator() {
   const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+
   return (
     <HomeTab.Navigator
       screenOptions={{
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index f876c6d53..5f9437319 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -122,6 +122,8 @@ interface TrackPropertiesMap {
   // ONBOARDING events
   'Onboarding:Begin': {}
   'Onboarding:Complete': {}
+  'Onboarding:Skipped': {}
+  'Onboarding:Reset': {}
 }
 
 interface ScreenPropertiesMap {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 001cdf8c3..94551e6ef 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -148,3 +148,110 @@ export const HITSLOP_10 = createHitslop(10)
 export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
 export const BACK_HITSLOP = HITSLOP_30
+
+export const RECOMMENDED_FEEDS = [
+  {
+    did: 'did:plc:hsqwcidfez66lwm3gxhfv5in',
+    rkey: 'aaaf2pqeodmpy',
+  },
+  {
+    did: 'did:plc:gekdk2nd47gkk3utfz2xf7cn',
+    rkey: 'aaap4tbjcfe5y',
+  },
+  {
+    did: 'did:plc:5rw2on4i56btlcajojaxwcat',
+    rkey: 'aaao6g552b33o',
+  },
+  {
+    did: 'did:plc:jfhpnnst6flqway4eaeqzj2a',
+    rkey: 'for-science',
+  },
+  {
+    did: 'did:plc:7q4nnnxawajbfaq7to5dpbsy',
+    rkey: 'bsky-news',
+  },
+  {
+    did: 'did:plc:jcoy7v3a2t4rcfdh6i4kza25',
+    rkey: 'astro',
+  },
+  {
+    did: 'did:plc:tenurhgjptubkk5zf5qhi3og',
+    rkey: 'h-nba',
+  },
+  {
+    did: 'did:plc:vpkhqolt662uhesyj6nxm7ys',
+    rkey: 'devfeed',
+  },
+  {
+    did: 'did:plc:cndfx4udwgvpjaakvxvh7wm5',
+    rkey: 'flipboard-tech',
+  },
+  {
+    did: 'did:plc:w4xbfzo7kqfes5zb7r6qv3rw',
+    rkey: 'blacksky',
+  },
+  {
+    did: 'did:plc:lptjvw6ut224kwrj7ub3sqbe',
+    rkey: 'aaaotfjzjplna',
+  },
+  {
+    did: 'did:plc:gkvpokm7ec5j5yxls6xk4e3z',
+    rkey: 'formula-one',
+  },
+  {
+    did: 'did:plc:q6gjnaw2blty4crticxkmujt',
+    rkey: 'positivifeed',
+  },
+  {
+    did: 'did:plc:l72uci4styb4jucsgcrrj5ap',
+    rkey: 'aaao5dzfm36u4',
+  },
+  {
+    did: 'did:plc:k3jkadxv5kkjgs6boyon7m6n',
+    rkey: 'aaaavlyvqzst2',
+  },
+  {
+    did: 'did:plc:nkahctfdi6bxk72umytfwghw',
+    rkey: 'aaado2uvfsc6w',
+  },
+  {
+    did: 'did:plc:epihigio3d7un7u3gpqiy5gv',
+    rkey: 'aaaekwsc7zsvs',
+  },
+  {
+    did: 'did:plc:qiknc4t5rq7yngvz7g4aezq7',
+    rkey: 'aaaejxlobe474',
+  },
+  {
+    did: 'did:plc:mlq4aycufcuolr7ax6sezpc4',
+    rkey: 'aaaoudweck6uy',
+  },
+  {
+    did: 'did:plc:rcez5hcvq3vzlu5x7xrjyccg',
+    rkey: 'aaadzjxbcddzi',
+  },
+  {
+    did: 'did:plc:lnxbuzaenlwjrncx6sc4cfdr',
+    rkey: 'aaab2vesjtszc',
+  },
+  {
+    did: 'did:plc:x3cya3wkt4n6u4ihmvpsc5if',
+    rkey: 'aaacynbxwimok',
+  },
+  {
+    did: 'did:plc:abv47bjgzjgoh3yrygwoi36x',
+    rkey: 'aaagt6amuur5e',
+  },
+  {
+    did: 'did:plc:ffkgesg3jsv2j7aagkzrtcvt',
+    rkey: 'aaacjerk7gwek',
+  },
+  {
+    did: 'did:plc:geoqe3qls5mwezckxxsewys2',
+    rkey: 'aaai43yetqshu',
+  },
+  {
+    did: 'did:plc:2wqomm3tjqbgktbrfwgvrw34',
+    rkey: 'authors',
+  },
+]
diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx
index 441585442..fd7e383f0 100644
--- a/src/lib/hooks/useWebMediaQueries.tsx
+++ b/src/lib/hooks/useWebMediaQueries.tsx
@@ -1,8 +1,14 @@
 import {useMediaQuery} from 'react-responsive'
+import {isNative} from 'platform/detection'
 
 export function useWebMediaQueries() {
   const isDesktop = useMediaQuery({
-    query: '(min-width: 1230px)',
+    query: '(min-width: 1224px)',
   })
-  return {isDesktop}
+  const isTabletOrMobile = useMediaQuery({query: '(max-width: 1224px)'})
+  const isMobile = useMediaQuery({query: '(max-width: 800px)'})
+  if (isNative) {
+    return {isMobile: true, isTabletOrMobile: true, isDesktop: false}
+  }
+  return {isMobile, isTabletOrMobile, isDesktop}
 }
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
new file mode 100644
index 000000000..09c9eac04
--- /dev/null
+++ b/src/state/models/discovery/onboarding.ts
@@ -0,0 +1,94 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from '../root-store'
+import {hasProp} from 'lib/type-guards'
+import {track} from 'lib/analytics/analytics'
+
+export const OnboardingScreenSteps = {
+  Welcome: 'Welcome',
+  RecommendedFeeds: 'RecommendedFeeds',
+  Home: 'Home',
+} as const
+
+type OnboardingStep =
+  (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps]
+const OnboardingStepsArray = Object.values(OnboardingScreenSteps)
+export class OnboardingModel {
+  // state
+  step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {
+      rootStore: false,
+      hydrate: false,
+      serialize: false,
+    })
+  }
+
+  serialize(): unknown {
+    return {
+      step: this.step,
+    }
+  }
+
+  hydrate(v: unknown) {
+    if (typeof v === 'object' && v !== null) {
+      if (
+        hasProp(v, 'step') &&
+        typeof v.step === 'string' &&
+        OnboardingStepsArray.includes(v.step as OnboardingStep)
+      ) {
+        this.step = v.step as OnboardingStep
+      }
+    } else {
+      // if there is no valid state, we'll just reset
+      this.reset()
+    }
+  }
+
+  /**
+   * Returns the name of the next screen in the onboarding process based on the current step or screen name provided.
+   * @param {OnboardingStep} [currentScreenName]
+   * @returns name of next screen in the onboarding process
+   */
+  next(currentScreenName?: OnboardingStep) {
+    currentScreenName = currentScreenName || this.step
+    if (currentScreenName === 'Welcome') {
+      this.step = 'RecommendedFeeds'
+      return this.step
+    } else if (this.step === 'RecommendedFeeds') {
+      this.finish()
+      return this.step
+    } else {
+      // if we get here, we're in an invalid state, let's just go Home
+      return 'Home'
+    }
+  }
+
+  start() {
+    this.step = 'Welcome'
+    track('Onboarding:Begin')
+  }
+
+  finish() {
+    this.step = 'Home'
+    track('Onboarding:Complete')
+  }
+
+  reset() {
+    this.step = 'Welcome'
+    track('Onboarding:Reset')
+  }
+
+  skip() {
+    this.step = 'Home'
+    track('Onboarding:Skipped')
+  }
+
+  get isComplete() {
+    return this.step === 'Home'
+  }
+
+  get isActive() {
+    return !this.isComplete
+  }
+}
diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts
index 3c6d52755..2de4534e7 100644
--- a/src/state/models/feeds/custom-feed.ts
+++ b/src/state/models/feeds/custom-feed.ts
@@ -67,6 +67,19 @@ export class CustomFeedModel {
     }
   }
 
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.data.displayName,
+        uri: this.uri,
+      })
+    }
+  }
+
   async unsave() {
     try {
       await this.rootStore.preferences.removeSavedFeed(this.uri)
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 1d6d3a0d0..6204e0d10 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -27,6 +27,7 @@ import {reset as resetNavigation} from '../../Navigation'
 // remove after backend testing finishes
 // -prf
 import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header'
+import {OnboardingModel} from './discovery/onboarding'
 
 export const appInfo = z.object({
   build: z.string(),
@@ -44,6 +45,7 @@ export class RootStoreModel {
   shell = new ShellUiModel(this)
   preferences = new PreferencesModel(this)
   me = new MeModel(this)
+  onboarding = new OnboardingModel(this)
   invitedUsers = new InvitedUsers(this)
   handleResolutions = new HandleResolutionsCache()
   profiles = new ProfilesCache(this)
@@ -70,6 +72,7 @@ export class RootStoreModel {
       appInfo: this.appInfo,
       session: this.session.serialize(),
       me: this.me.serialize(),
+      onboarding: this.onboarding.serialize(),
       shell: this.shell.serialize(),
       preferences: this.preferences.serialize(),
       invitedUsers: this.invitedUsers.serialize(),
@@ -88,6 +91,9 @@ export class RootStoreModel {
       if (hasProp(v, 'me')) {
         this.me.hydrate(v.me)
       }
+      if (hasProp(v, 'onboarding')) {
+        this.onboarding.hydrate(v.onboarding)
+      }
       if (hasProp(v, 'session')) {
         this.session.hydrate(v.session)
       }
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index 04e1554c6..d9d4f51b9 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -109,10 +109,10 @@ export class CreateAccountModel {
     this.setError('')
     this.setIsProcessing(true)
 
-    // open the onboarding modal after the session is created
+    // open the onboarding screens after the session is created
     const sessionReadySub = this.rootStore.onSessionReady(() => {
       sessionReadySub.remove()
-      this.rootStore.shell.openModal({name: 'onboarding'})
+      this.rootStore.onboarding.start()
     })
 
     try {
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index d19de4b96..33fdd5710 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -136,10 +136,6 @@ export interface PostLanguagesSettingsModal {
   name: 'post-languages-settings'
 }
 
-export interface OnboardingModal {
-  name: 'onboarding'
-}
-
 export type Modal =
   // Account
   | AddAppPasswordModal
@@ -171,9 +167,6 @@ export type Modal =
   | WaitlistModal
   | InviteCodesModal
 
-  // Onboarding
-  | OnboardingModal
-
   // Generic
   | ConfirmModal
 
diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx
new file mode 100644
index 000000000..065d4d244
--- /dev/null
+++ b/src/view/com/auth/Onboarding.tsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import {SafeAreaView} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {Welcome} from './onboarding/Welcome'
+import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
+
+export const Onboarding = observer(() => {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  React.useEffect(() => {
+    store.shell.setMinimalShellMode(true)
+  }, [store])
+
+  const next = () => store.onboarding.next()
+  const skip = () => store.onboarding.skip()
+
+  return (
+    <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
+      <ErrorBoundary>
+        {store.onboarding.step === 'Welcome' && (
+          <Welcome skip={skip} next={next} />
+        )}
+        {store.onboarding.step === 'RecommendedFeeds' && (
+          <RecommendedFeeds next={next} />
+        )}
+      </ErrorBoundary>
+    </SafeAreaView>
+  )
+})
diff --git a/src/view/com/auth/onboarding/Onboarding.tsx b/src/view/com/auth/onboarding/Onboarding.tsx
deleted file mode 100644
index 28e4419d7..000000000
--- a/src/view/com/auth/onboarding/Onboarding.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Welcome} from './Welcome'
-import {useStores} from 'state/index'
-import {track} from 'lib/analytics/analytics'
-
-enum OnboardingStep {
-  WELCOME = 'WELCOME',
-  // SELECT_INTERESTS = 'SELECT_INTERESTS',
-  COMPLETE = 'COMPLETE',
-}
-type OnboardingState = {
-  currentStep: OnboardingStep
-}
-type Action = {type: 'NEXT_STEP'}
-const initialState: OnboardingState = {
-  currentStep: OnboardingStep.WELCOME,
-}
-const reducer = (state: OnboardingState, action: Action): OnboardingState => {
-  switch (action.type) {
-    case 'NEXT_STEP':
-      switch (state.currentStep) {
-        case OnboardingStep.WELCOME:
-          track('Onboarding:Begin')
-          return {...state, currentStep: OnboardingStep.COMPLETE}
-        case OnboardingStep.COMPLETE:
-          track('Onboarding:Complete')
-          return state
-        default:
-          return state
-      }
-    default:
-      return state
-  }
-}
-
-export const Onboarding = () => {
-  const pal = usePalette('default')
-  const rootStore = useStores()
-  const [state, dispatch] = React.useReducer(reducer, initialState)
-  const next = React.useCallback(
-    () => dispatch({type: 'NEXT_STEP'}),
-    [dispatch],
-  )
-
-  React.useEffect(() => {
-    if (state.currentStep === OnboardingStep.COMPLETE) {
-      // navigate to home
-      rootStore.shell.closeModal()
-    }
-  }, [state.currentStep, rootStore.shell])
-
-  return (
-    <View style={[pal.view, styles.container]}>
-      {state.currentStep === OnboardingStep.WELCOME && <Welcome next={next} />}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingHorizontal: 20,
-  },
-})
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
new file mode 100644
index 000000000..28dc2cdd0
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -0,0 +1,176 @@
+import React from 'react'
+import {FlatList, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
+import {Text} from 'view/com/util/text/Text'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
+import {Button} from 'view/com/util/forms/Button'
+import {RecommendedFeedsItem} from './RecommendedFeedsItem'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from 'lib/hooks/usePalette'
+import {RECOMMENDED_FEEDS} from 'lib/constants'
+
+type Props = {
+  next: () => void
+}
+export const RecommendedFeeds = observer(({next}: Props) => {
+  const pal = usePalette('default')
+  const {isTabletOrMobile} = useWebMediaQueries()
+
+  const title = (
+    <>
+      <Text
+        style={[
+          pal.textLight,
+          tdStyles.title1,
+          isTabletOrMobile && tdStyles.title1Small,
+        ]}>
+        Choose your
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Recomended
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Feeds
+      </Text>
+      <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
+        Feeds are created by users to curate content. Choose some feeds that you
+        find interesting.
+      </Text>
+      <View
+        style={{
+          flexDirection: 'row',
+          justifyContent: 'flex-end',
+          marginTop: 20,
+        }}>
+        <Button onPress={next} testID="continueBtn">
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              paddingLeft: 2,
+              gap: 6,
+            }}>
+            <Text
+              type="2xl-medium"
+              style={{color: '#fff', position: 'relative', top: -1}}>
+              Done
+            </Text>
+            <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
+          </View>
+        </Button>
+      </View>
+    </>
+  )
+
+  return (
+    <>
+      <TabletOrDesktop>
+        <TitleColumnLayout
+          testID="recommendedFeedsScreen"
+          title={title}
+          horizontal
+          titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
+          contentStyle={{paddingHorizontal: 0}}>
+          <FlatList
+            data={RECOMMENDED_FEEDS}
+            renderItem={({item}) => <RecommendedFeedsItem {...item} />}
+            keyExtractor={item => item.did + item.rkey}
+            style={{flex: 1}}
+          />
+        </TitleColumnLayout>
+      </TabletOrDesktop>
+      <Mobile>
+        <View style={[mStyles.container]} testID="recommendedFeedsScreen">
+          <ViewHeader
+            title="Recommended Feeds"
+            showBackButton={false}
+            showOnDesktop
+          />
+          <Text type="lg-medium" style={[pal.text, mStyles.header]}>
+            Check out some recommended feeds. Tap + to add them to your list of
+            pinned feeds.
+          </Text>
+
+          <FlatList
+            data={RECOMMENDED_FEEDS}
+            renderItem={({item}) => <RecommendedFeedsItem {...item} />}
+            keyExtractor={item => item.did + item.rkey}
+            style={{flex: 1}}
+          />
+
+          <Button
+            onPress={next}
+            label="Continue"
+            testID="continueBtn"
+            style={mStyles.button}
+            labelStyle={mStyles.buttonText}
+          />
+        </View>
+      </Mobile>
+    </>
+  )
+})
+
+const tdStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    marginHorizontal: 16,
+    justifyContent: 'space-between',
+  },
+  title1: {
+    fontSize: 36,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title1Small: {
+    fontSize: 24,
+  },
+  title2: {
+    fontSize: 58,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title2Small: {
+    fontSize: 36,
+  },
+  description: {
+    maxWidth: 400,
+    marginTop: 10,
+    marginLeft: 'auto',
+    textAlign: 'right',
+  },
+})
+
+const mStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    justifyContent: 'space-between',
+  },
+  header: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+  },
+  button: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+    marginTop: 16,
+  },
+  buttonText: {
+    textAlign: 'center',
+    fontSize: 18,
+    paddingVertical: 4,
+  },
+})
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
new file mode 100644
index 000000000..d16b3213e
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -0,0 +1,142 @@
+import React from 'react'
+import {View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Text} from 'view/com/util/text/Text'
+import {Button} from 'view/com/util/forms/Button'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import * as Toast from 'view/com/util/Toast'
+import {HeartIcon} from 'lib/icons'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useCustomFeed} from 'lib/hooks/useCustomFeed'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {makeRecordUri} from 'lib/strings/url-helpers'
+import {sanitizeHandle} from 'lib/strings/handles'
+
+export const RecommendedFeedsItem = observer(
+  ({did, rkey}: {did: string; rkey: string}) => {
+    const {isMobile} = useWebMediaQueries()
+    const pal = usePalette('default')
+    const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
+    const item = useCustomFeed(uri)
+    if (!item) return null
+    const onToggle = async () => {
+      if (item.isSaved) {
+        try {
+          await item.unsave()
+        } catch (e) {
+          Toast.show('There was an issue contacting your server')
+          console.error('Failed to unsave feed', {e})
+        }
+      } else {
+        try {
+          await item.save()
+          await item.pin()
+        } catch (e) {
+          Toast.show('There was an issue contacting your server')
+          console.error('Failed to pin feed', {e})
+        }
+      }
+    }
+    return (
+      <View testID={`feed-${item.displayName}`}>
+        <View
+          style={[
+            pal.border,
+            {
+              flex: isMobile ? 1 : undefined,
+              flexDirection: 'row',
+              gap: 18,
+              maxWidth: isMobile ? undefined : 670,
+              borderRightWidth: isMobile ? undefined : 1,
+              paddingHorizontal: 24,
+              paddingVertical: isMobile ? 12 : 24,
+              borderTopWidth: 1,
+            },
+          ]}>
+          <View style={{marginTop: 2}}>
+            <UserAvatar type="algo" size={42} avatar={item.data.avatar} />
+          </View>
+          <View style={{flex: isMobile ? 1 : undefined}}>
+            <Text
+              type="2xl-bold"
+              numberOfLines={1}
+              style={[pal.text, {fontSize: 19}]}>
+              {item.displayName}
+            </Text>
+
+            <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
+              by {sanitizeHandle(item.data.creator.handle, '@')}
+            </Text>
+
+            {item.data.description ? (
+              <Text
+                type="xl"
+                style={[
+                  pal.text,
+                  {
+                    flex: isMobile ? 1 : undefined,
+                    maxWidth: 550,
+                    marginBottom: 18,
+                  },
+                ]}
+                numberOfLines={6}>
+                {item.data.description}
+              </Text>
+            ) : null}
+
+            <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
+              <Button
+                type="inverted"
+                style={{paddingVertical: 6}}
+                onPress={onToggle}>
+                <View
+                  style={{
+                    flexDirection: 'row',
+                    alignItems: 'center',
+                    paddingRight: 2,
+                    gap: 6,
+                  }}>
+                  {item.isSaved ? (
+                    <>
+                      <FontAwesomeIcon
+                        icon="check"
+                        size={16}
+                        color={pal.colors.textInverted}
+                      />
+                      <Text type="lg-medium" style={pal.textInverted}>
+                        Added
+                      </Text>
+                    </>
+                  ) : (
+                    <>
+                      <FontAwesomeIcon
+                        icon="plus"
+                        size={16}
+                        color={pal.colors.textInverted}
+                      />
+                      <Text type="lg-medium" style={pal.textInverted}>
+                        Add
+                      </Text>
+                    </>
+                  )}
+                </View>
+              </Button>
+
+              <View style={{flexDirection: 'row', gap: 4}}>
+                <HeartIcon
+                  size={16}
+                  strokeWidth={2.5}
+                  style={[pal.textLight, {position: 'relative', top: 2}]}
+                />
+                <Text type="lg-medium" style={[pal.text, pal.textLight]}>
+                  {item.data.likeCount || 0}
+                </Text>
+              </View>
+            </View>
+          </View>
+        </View>
+      </View>
+    )
+  },
+)
diff --git a/src/view/com/auth/onboarding/Welcome.tsx b/src/view/com/auth/onboarding/Welcome.tsx
index 87435c88a..b44b58f84 100644
--- a/src/view/com/auth/onboarding/Welcome.tsx
+++ b/src/view/com/auth/onboarding/Welcome.tsx
@@ -1,92 +1,10 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Text} from 'view/com/util/text/Text'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Button} from 'view/com/util/forms/Button'
-
-export const Welcome = ({next}: {next: () => void}) => {
-  const pal = usePalette('default')
-  return (
-    <View style={[styles.container]}>
-      <View testID="welcomeScreen">
-        <Text style={[pal.text, styles.title]}>Welcome to </Text>
-        <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
-
-        <View style={styles.spacer} />
-
-        <View style={[styles.row]}>
-          <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
-          <View style={[styles.rowText]}>
-            <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is public.
-            </Text>
-            <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Your posts, likes, and blocks are public. Mutes are private.
-            </Text>
-          </View>
-        </View>
-        <View style={[styles.row]}>
-          <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
-          <View style={[styles.rowText]}>
-            <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is open.
-            </Text>
-            <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Never lose access to your followers and data.
-            </Text>
-          </View>
-        </View>
-        <View style={[styles.row]}>
-          <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
-          <View style={[styles.rowText]}>
-            <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is flexible.
-            </Text>
-            <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Choose the algorithms that power your experience with custom
-              feeds.
-            </Text>
-          </View>
-        </View>
-      </View>
-
-      <Button
-        onPress={next}
-        label="Continue"
-        testID="continueBtn"
-        labelStyle={styles.buttonText}
-      />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    marginVertical: 60,
-    justifyContent: 'space-between',
-  },
-  title: {
-    fontSize: 48,
-    fontWeight: '800',
-  },
-  row: {
-    flexDirection: 'row',
-    columnGap: 20,
-    alignItems: 'center',
-    marginVertical: 20,
-  },
-  rowText: {
-    flex: 1,
-  },
-  spacer: {
-    height: 20,
-  },
-  buttonText: {
-    textAlign: 'center',
-    fontSize: 18,
-    marginVertical: 4,
-  },
-})
+import 'react'
+import {withBreakpoints} from 'view/com/util/layouts/withBreakpoints'
+import {WelcomeDesktop} from './WelcomeDesktop'
+import {WelcomeMobile} from './WelcomeMobile'
+
+export const Welcome = withBreakpoints(
+  WelcomeMobile,
+  WelcomeDesktop,
+  WelcomeDesktop,
+)
diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
new file mode 100644
index 000000000..e63693443
--- /dev/null
+++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
@@ -0,0 +1,123 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useMediaQuery} from 'react-responsive'
+import {Text} from 'view/com/util/text/Text'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
+import {Button} from 'view/com/util/forms/Button'
+import {observer} from 'mobx-react-lite'
+
+type Props = {
+  next: () => void
+  skip: () => void
+}
+
+export const WelcomeDesktop = observer(({next}: Props) => {
+  const pal = usePalette('default')
+  const horizontal = useMediaQuery({
+    query: '(min-width: 1230px)',
+  })
+  const title = (
+    <>
+      <Text
+        style={[
+          pal.textLight,
+          {
+            fontSize: 36,
+            fontWeight: '800',
+            textAlign: horizontal ? 'right' : 'left',
+          },
+        ]}>
+        Welcome to
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          {
+            fontSize: 72,
+            fontWeight: '800',
+            textAlign: horizontal ? 'right' : 'left',
+          },
+        ]}>
+        Bluesky
+      </Text>
+    </>
+  )
+  return (
+    <TitleColumnLayout
+      testID="welcomeOnboarding"
+      title={title}
+      horizontal={horizontal}
+      titleStyle={horizontal ? {paddingBottom: 160} : undefined}>
+      <View style={[styles.row]}>
+        <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
+        <View style={[styles.rowText]}>
+          <Text type="xl-bold" style={[pal.text]}>
+            Bluesky is public.
+          </Text>
+          <Text type="xl" style={[pal.text, s.pt2]}>
+            Your posts, likes, and blocks are public. Mutes are private.
+          </Text>
+        </View>
+      </View>
+      <View style={[styles.row]}>
+        <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
+        <View style={[styles.rowText]}>
+          <Text type="xl-bold" style={[pal.text]}>
+            Bluesky is open.
+          </Text>
+          <Text type="xl" style={[pal.text, s.pt2]}>
+            Never lose access to your followers and data.
+          </Text>
+        </View>
+      </View>
+      <View style={[styles.row]}>
+        <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
+        <View style={[styles.rowText]}>
+          <Text type="xl-bold" style={[pal.text]}>
+            Bluesky is flexible.
+          </Text>
+          <Text type="xl" style={[pal.text, s.pt2]}>
+            Choose the algorithms that power your experience with custom feeds.
+          </Text>
+        </View>
+      </View>
+      <View style={styles.spacer} />
+      <View style={{flexDirection: 'row'}}>
+        <Button onPress={next} testID="continueBtn">
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              paddingLeft: 2,
+              gap: 6,
+            }}>
+            <Text
+              type="2xl-medium"
+              style={{color: '#fff', position: 'relative', top: -1}}>
+              Next
+            </Text>
+            <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
+          </View>
+        </Button>
+      </View>
+    </TitleColumnLayout>
+  )
+})
+
+const styles = StyleSheet.create({
+  row: {
+    flexDirection: 'row',
+    columnGap: 20,
+    alignItems: 'center',
+    marginVertical: 20,
+  },
+  rowText: {
+    flex: 1,
+  },
+  spacer: {
+    height: 20,
+  },
+})
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
new file mode 100644
index 000000000..eb72de836
--- /dev/null
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -0,0 +1,123 @@
+import React from 'react'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {Text} from 'view/com/util/text/Text'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Button} from 'view/com/util/forms/Button'
+import {observer} from 'mobx-react-lite'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {isDesktopWeb} from 'platform/detection'
+
+type Props = {
+  next: () => void
+  skip: () => void
+}
+
+export const WelcomeMobile = observer(({next, skip}: Props) => {
+  const pal = usePalette('default')
+
+  return (
+    <View style={[styles.container]} testID="welcomeOnboarding">
+      <ViewHeader
+        showOnDesktop
+        showBorder={false}
+        showBackButton={false}
+        title=""
+        renderButton={() => {
+          return (
+            <Pressable
+              accessibilityRole="button"
+              style={[s.flexRow, s.alignCenter]}
+              onPress={skip}>
+              <Text style={[pal.link]}>Skip</Text>
+              <FontAwesomeIcon
+                icon={'chevron-right'}
+                size={14}
+                color={pal.colors.link}
+              />
+            </Pressable>
+          )
+        }}
+      />
+      <View>
+        <Text style={[pal.text, styles.title]}>
+          Welcome to{' '}
+          <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
+        </Text>
+        <View style={styles.spacer} />
+        <View style={[styles.row]}>
+          <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
+          <View style={[styles.rowText]}>
+            <Text type="lg-bold" style={[pal.text]}>
+              Bluesky is public.
+            </Text>
+            <Text type="lg-thin" style={[pal.text, s.pt2]}>
+              Your posts, likes, and blocks are public. Mutes are private.
+            </Text>
+          </View>
+        </View>
+        <View style={[styles.row]}>
+          <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
+          <View style={[styles.rowText]}>
+            <Text type="lg-bold" style={[pal.text]}>
+              Bluesky is open.
+            </Text>
+            <Text type="lg-thin" style={[pal.text, s.pt2]}>
+              Never lose access to your followers and data.
+            </Text>
+          </View>
+        </View>
+        <View style={[styles.row]}>
+          <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
+          <View style={[styles.rowText]}>
+            <Text type="lg-bold" style={[pal.text]}>
+              Bluesky is flexible.
+            </Text>
+            <Text type="lg-thin" style={[pal.text, s.pt2]}>
+              Choose the algorithms that power your experience with custom
+              feeds.
+            </Text>
+          </View>
+        </View>
+      </View>
+
+      <Button
+        onPress={next}
+        label="Continue"
+        testID="continueBtn"
+        labelStyle={styles.buttonText}
+      />
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    marginBottom: isDesktopWeb ? 30 : 60,
+    marginHorizontal: 16,
+    justifyContent: 'space-between',
+  },
+  title: {
+    fontSize: 42,
+    fontWeight: '800',
+  },
+  row: {
+    flexDirection: 'row',
+    columnGap: 20,
+    alignItems: 'center',
+    marginVertical: 20,
+  },
+  rowText: {
+    flex: 1,
+  },
+  spacer: {
+    height: 20,
+  },
+  buttonText: {
+    textAlign: 'center',
+    fontSize: 18,
+    marginVertical: 4,
+  },
+})
diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx
index 8e57669be..c81c2d5df 100644
--- a/src/view/com/auth/withAuthRequired.tsx
+++ b/src/view/com/auth/withAuthRequired.tsx
@@ -9,6 +9,7 @@ import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
 import {CenteredView} from '../util/Views'
 import {LoggedOut} from './LoggedOut'
+import {Onboarding} from './Onboarding'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {STATUS_PAGE_URL} from 'lib/constants'
@@ -24,6 +25,9 @@ export const withAuthRequired = <P extends object>(
     if (!store.session.hasSession) {
       return <LoggedOut />
     }
+    if (store.onboarding.isActive) {
+      return <Onboarding />
+    }
     return <Component {...props} />
   })
 
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index d6d1e212d..4a5a7c504 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as OnboardingModal from './OnboardingModal'
 import * as ModerationDetailsModal from './ModerationDetails'
 
 const DEFAULT_SNAPPOINTS = ['90%']
@@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'post-languages-settings') {
     snapPoints = PostLanguagesSettingsModal.snapPoints
     element = <PostLanguagesSettingsModal.Component />
-  } else if (activeModal?.name === 'onboarding') {
-    snapPoints = OnboardingModal.snapPoints
-    element = <OnboardingModal.Component />
   } else if (activeModal?.name === 'moderation-details') {
     snapPoints = ModerationDetailsModal.snapPoints
     element = <ModerationDetailsModal.Component {...activeModal} />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 05bb7161f..5cfdd6bb3 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -26,7 +26,6 @@ import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as OnboardingModal from './OnboardingModal'
 import * as ModerationDetailsModal from './ModerationDetails'
 
 export const ModalsContainer = observer(function ModalsContainer() {
@@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <AltTextImageModal.Component {...modal} />
   } else if (modal.name === 'edit-image') {
     element = <EditImageModal.Component {...modal} />
-  } else if (modal.name === 'onboarding') {
-    element = <OnboardingModal.Component />
   } else if (modal.name === 'moderation-details') {
     element = <ModerationDetailsModal.Component {...modal} />
   } else {
diff --git a/src/view/com/modals/OnboardingModal.tsx b/src/view/com/modals/OnboardingModal.tsx
deleted file mode 100644
index c70f4fd62..000000000
--- a/src/view/com/modals/OnboardingModal.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from 'react'
-import {Onboarding} from '../auth/onboarding/Onboarding'
-
-export const snapPoints = ['90%']
-
-export function Component() {
-  return <Onboarding />
-}
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index f5a921ac0..7482db8eb 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -17,6 +17,7 @@ const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 export const ViewHeader = observer(function ({
   title,
   canGoBack,
+  showBackButton = true,
   hideOnScroll,
   showOnDesktop,
   showBorder,
@@ -24,6 +25,7 @@ export const ViewHeader = observer(function ({
 }: {
   title: string
   canGoBack?: boolean
+  showBackButton?: boolean
   hideOnScroll?: boolean
   showOnDesktop?: boolean
   showBorder?: boolean
@@ -49,7 +51,13 @@ export const ViewHeader = observer(function ({
 
   if (isDesktopWeb) {
     if (showOnDesktop) {
-      return <DesktopWebHeader title={title} renderButton={renderButton} />
+      return (
+        <DesktopWebHeader
+          title={title}
+          renderButton={renderButton}
+          showBorder={showBorder}
+        />
+      )
     }
     return null
   } else {
@@ -59,30 +67,32 @@ export const ViewHeader = observer(function ({
 
     return (
       <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
-        <TouchableOpacity
-          testID="viewHeaderDrawerBtn"
-          onPress={canGoBack ? onPressBack : onPressMenu}
-          hitSlop={BACK_HITSLOP}
-          style={canGoBack ? styles.backBtn : styles.backBtnWide}
-          accessibilityRole="button"
-          accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
-          accessibilityHint={
-            canGoBack ? '' : 'Access navigation links and settings'
-          }>
-          {canGoBack ? (
-            <FontAwesomeIcon
-              size={18}
-              icon="angle-left"
-              style={[styles.backIcon, pal.text]}
-            />
-          ) : (
-            <FontAwesomeIcon
-              size={18}
-              icon="bars"
-              style={[styles.backIcon, pal.textLight]}
-            />
-          )}
-        </TouchableOpacity>
+        {showBackButton ? (
+          <TouchableOpacity
+            testID="viewHeaderDrawerBtn"
+            onPress={canGoBack ? onPressBack : onPressMenu}
+            hitSlop={BACK_HITSLOP}
+            style={canGoBack ? styles.backBtn : styles.backBtnWide}
+            accessibilityRole="button"
+            accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
+            accessibilityHint={
+              canGoBack ? '' : 'Access navigation links and settings'
+            }>
+            {canGoBack ? (
+              <FontAwesomeIcon
+                size={18}
+                icon="angle-left"
+                style={[styles.backIcon, pal.text]}
+              />
+            ) : (
+              <FontAwesomeIcon
+                size={18}
+                icon="bars"
+                style={[styles.backIcon, pal.textLight]}
+              />
+            )}
+          </TouchableOpacity>
+        ) : null}
         <View style={styles.titleContainer} pointerEvents="none">
           <Text type="title" style={[pal.text, styles.title]}>
             {title}
@@ -90,9 +100,9 @@ export const ViewHeader = observer(function ({
         </View>
         {renderButton ? (
           renderButton()
-        ) : (
+        ) : showBackButton ? (
           <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
-        )}
+        ) : null}
       </Container>
     )
   }
@@ -101,13 +111,23 @@ export const ViewHeader = observer(function ({
 function DesktopWebHeader({
   title,
   renderButton,
+  showBorder = true,
 }: {
   title: string
   renderButton?: () => JSX.Element
+  showBorder?: boolean
 }) {
   const pal = usePalette('default')
   return (
-    <CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
+    <CenteredView
+      style={[
+        styles.header,
+        styles.desktopHeader,
+        pal.border,
+        {
+          borderBottomWidth: showBorder ? 1 : 0,
+        },
+      ]}>
       <View style={styles.titleContainer} pointerEvents="none">
         <Text type="title-lg" style={[pal.text, styles.title]}>
           {title}
@@ -195,13 +215,11 @@ const styles = StyleSheet.create({
     width: '100%',
   },
   desktopHeader: {
-    borderBottomWidth: 1,
     paddingVertical: 12,
   },
   border: {
     borderBottomWidth: 1,
   },
-
   titleContainer: {
     marginLeft: 'auto',
     marginRight: 'auto',
diff --git a/src/view/com/util/layouts/Breakpoints.tsx b/src/view/com/util/layouts/Breakpoints.tsx
new file mode 100644
index 000000000..51c3ccd5a
--- /dev/null
+++ b/src/view/com/util/layouts/Breakpoints.tsx
@@ -0,0 +1,8 @@
+import React from 'react'
+
+export const Desktop = ({}: React.PropsWithChildren<{}>) => null
+export const TabletOrDesktop = ({}: React.PropsWithChildren<{}>) => null
+export const Tablet = ({}: React.PropsWithChildren<{}>) => null
+export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) =>
+  children
+export const Mobile = ({children}: React.PropsWithChildren<{}>) => children
diff --git a/src/view/com/util/layouts/Breakpoints.web.tsx b/src/view/com/util/layouts/Breakpoints.web.tsx
new file mode 100644
index 000000000..7031a1735
--- /dev/null
+++ b/src/view/com/util/layouts/Breakpoints.web.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import MediaQuery from 'react-responsive'
+
+export const Desktop = ({children}: React.PropsWithChildren<{}>) => (
+  <MediaQuery minWidth={1224}>{children}</MediaQuery>
+)
+export const TabletOrDesktop = ({children}: React.PropsWithChildren<{}>) => (
+  <MediaQuery minWidth={800}>{children}</MediaQuery>
+)
+export const Tablet = ({children}: React.PropsWithChildren<{}>) => (
+  <MediaQuery minWidth={800} maxWidth={1224}>
+    {children}
+  </MediaQuery>
+)
+export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) => (
+  <MediaQuery maxWidth={1224}>{children}</MediaQuery>
+)
+export const Mobile = ({children}: React.PropsWithChildren<{}>) => (
+  <MediaQuery maxWidth={800}>{children}</MediaQuery>
+)
diff --git a/src/view/com/util/layouts/TitleColumnLayout.tsx b/src/view/com/util/layouts/TitleColumnLayout.tsx
new file mode 100644
index 000000000..49ad9fcdb
--- /dev/null
+++ b/src/view/com/util/layouts/TitleColumnLayout.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+
+interface Props {
+  testID?: string
+  title: JSX.Element
+  horizontal: boolean
+  titleStyle?: StyleProp<ViewStyle>
+  contentStyle?: StyleProp<ViewStyle>
+}
+
+export function TitleColumnLayout({
+  testID,
+  title,
+  horizontal,
+  children,
+  titleStyle,
+  contentStyle,
+}: React.PropsWithChildren<Props>) {
+  const pal = usePalette('default')
+  const titleBg = useColorSchemeStyle(pal.viewLight, pal.view)
+  const contentBg = useColorSchemeStyle(pal.view, {
+    backgroundColor: pal.colors.background,
+    borderColor: pal.colors.border,
+    borderLeftWidth: 1,
+  })
+
+  const layoutStyles = horizontal ? styles2Column : styles1Column
+  return (
+    <View testID={testID} style={layoutStyles.container}>
+      <View style={[layoutStyles.title, titleBg, titleStyle]}>{title}</View>
+      <View style={[layoutStyles.content, contentBg, contentStyle]}>
+        {children}
+      </View>
+    </View>
+  )
+}
+
+const styles2Column = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    height: '100%',
+  },
+  title: {
+    flex: 1,
+    paddingHorizontal: 40,
+    paddingBottom: 80,
+    justifyContent: 'center',
+  },
+  content: {
+    flex: 2,
+    paddingHorizontal: 40,
+    justifyContent: 'center',
+  },
+})
+
+const styles1Column = StyleSheet.create({
+  container: {},
+  title: {
+    paddingHorizontal: 40,
+    paddingVertical: 40,
+  },
+  content: {
+    paddingHorizontal: 40,
+    paddingVertical: 40,
+  },
+})
diff --git a/src/view/com/util/layouts/withBreakpoints.tsx b/src/view/com/util/layouts/withBreakpoints.tsx
new file mode 100644
index 000000000..dc3f50dc9
--- /dev/null
+++ b/src/view/com/util/layouts/withBreakpoints.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import {isNative} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+
+export const withBreakpoints =
+  <P extends object>(
+    Mobile: React.ComponentType<P>,
+    Tablet: React.ComponentType<P>,
+    Desktop: React.ComponentType<P>,
+  ): React.FC<P> =>
+  (props: P) => {
+    const {isMobile, isTabletOrMobile} = useWebMediaQueries()
+
+    if (isMobile || isNative) {
+      return <Mobile {...props} />
+    }
+    if (isTabletOrMobile) {
+      return <Tablet {...props} />
+    }
+    return <Desktop {...props} />
+  }
diff --git a/src/view/index.ts b/src/view/index.ts
index 1c3dc3937..2e4c08ec7 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -92,6 +92,7 @@ import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
 import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
 import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
 import {faList} from '@fortawesome/free-solid-svg-icons/faList'
+import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 
 export function setup() {
   library.add(
@@ -187,5 +188,6 @@ export function setup() {
     faPlay,
     faPause,
     faList,
+    faChevronRight,
   )
 }
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 9259d4bea..7262756d3 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -31,7 +31,7 @@ const POLL_FREQ = 30e3 // 30sec
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export const HomeScreen = withAuthRequired(
-  observer((_opts: Props) => {
+  observer(({}: Props) => {
     const store = useStores()
     const pagerRef = React.useRef<PagerRef>(null)
     const [selectedPage, setSelectedPage] = React.useState(0)
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index b20d36310..481d77086 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -162,6 +162,11 @@ export const SettingsScreen = withAuthRequired(
       Toast.show('Preferences reset')
     }, [store])
 
+    const onPressResetOnboarding = React.useCallback(async () => {
+      store.onboarding.reset()
+      Toast.show('Onboarding reset')
+    }, [store])
+
     const onPressBuildInfo = React.useCallback(() => {
       Clipboard.setString(
         `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
@@ -533,6 +538,16 @@ export const SettingsScreen = withAuthRequired(
                   Reset preferences state
                 </Text>
               </TouchableOpacity>
+              <TouchableOpacity
+                style={[pal.view, styles.linkCardNoIcon]}
+                onPress={onPressResetOnboarding}
+                accessibilityRole="button"
+                accessibilityHint="Reset onboarding"
+                accessibilityLabel="Resets the onboarding state">
+                <Text type="lg" style={pal.text}>
+                  Reset onboarding state
+                </Text>
+              </TouchableOpacity>
             </>
           ) : null}
           <View style={[styles.footer]}>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 16ed17a5b..e36439c82 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -20,7 +20,6 @@ import {NavigationProp} from 'lib/routes/types'
 const ShellInner = observer(() => {
   const store = useStores()
   const {isDesktop} = useWebMediaQueries()
-
   const navigator = useNavigation<NavigationProp>()
 
   useEffect(() => {
@@ -29,6 +28,9 @@ const ShellInner = observer(() => {
     })
   }, [navigator, store.shell])
 
+  const showBottomBar = !isDesktop && !store.onboarding.isActive
+  const showSideNavs =
+    isDesktop && store.session.hasSession && !store.onboarding.isActive
   return (
     <>
       <View style={s.hContentRegion}>
@@ -36,7 +38,7 @@ const ShellInner = observer(() => {
           <FlatNavigator />
         </ErrorBoundary>
       </View>
-      {isDesktop && store.session.hasSession && (
+      {showSideNavs && (
         <>
           <DesktopLeftNav />
           <DesktopRightNav />
@@ -51,7 +53,7 @@ const ShellInner = observer(() => {
         onPost={store.shell.composerOpts?.onPost}
         mention={store.shell.composerOpts?.mention}
       />
-      {!isDesktop && <BottomBarWeb />}
+      {showBottomBar && <BottomBarWeb />}
       <ModalsContainer />
       <Lightbox />
       {!isDesktop && store.shell.isDrawerOpen && (