about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx33
-rw-r--r--src/App.web.tsx24
-rw-r--r--src/Navigation.tsx287
-rw-r--r--src/app.json4
-rw-r--r--src/index.js12
-rw-r--r--src/lib/analytics.tsx2
-rw-r--r--src/lib/api/index.ts4
-rw-r--r--src/lib/assets.native.ts6
-rw-r--r--src/lib/build-flags.ts1
-rw-r--r--src/lib/constants.ts2
-rw-r--r--src/lib/hooks/useColorSchemeStyle.ts4
-rw-r--r--src/lib/hooks/usePermissions.ts50
-rw-r--r--src/lib/icons.tsx83
-rw-r--r--src/lib/link-meta/bsky.ts153
-rw-r--r--src/lib/media/manip.web.ts41
-rw-r--r--src/lib/media/picker.web.tsx5
-rw-r--r--src/lib/media/util.ts7
-rw-r--r--src/lib/notifee.ts4
-rw-r--r--src/lib/permissions.ts61
-rw-r--r--src/lib/permissions.web.ts22
-rw-r--r--src/lib/routes/helpers.ts77
-rw-r--r--src/lib/routes/router.ts55
-rw-r--r--src/lib/routes/types.ts61
-rw-r--r--src/lib/styles.ts6
-rw-r--r--src/routes.ts16
-rw-r--r--src/state/models/navigation.ts434
-rw-r--r--src/state/models/root-store.ts12
-rw-r--r--src/state/models/shell-ui.ts11
-rw-r--r--src/state/models/user-local-photos.ts24
-rw-r--r--src/view/com/composer/Composer.tsx (renamed from src/view/com/composer/ComposePost.tsx)378
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx1
-rw-r--r--src/view/com/composer/Prompt.tsx26
-rw-r--r--src/view/com/composer/autocomplete/Autocomplete.tsx77
-rw-r--r--src/view/com/composer/autocomplete/Autocomplete.web.tsx59
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx84
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.tsx187
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.web.tsx10
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx94
-rw-r--r--src/view/com/composer/photos/SelectedPhotos.tsx (renamed from src/view/com/composer/SelectedPhoto.tsx)2
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx252
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx169
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx75
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx157
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts90
-rw-r--r--src/view/com/login/CreateAccount.tsx3
-rw-r--r--src/view/com/login/Signin.tsx22
-rw-r--r--src/view/com/modals/ChangeHandle.tsx5
-rw-r--r--src/view/com/modals/DeleteAccount.tsx7
-rw-r--r--src/view/com/modals/EditProfile.tsx5
-rw-r--r--src/view/com/modals/ServerInput.tsx3
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx3
-rw-r--r--src/view/com/notifications/FeedItem.tsx40
-rw-r--r--src/view/com/post-thread/PostThread.tsx56
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx19
-rw-r--r--src/view/com/post/Post.tsx10
-rw-r--r--src/view/com/posts/Feed.tsx14
-rw-r--r--src/view/com/posts/FeedItem.tsx24
-rw-r--r--src/view/com/profile/ProfileCard.tsx10
-rw-r--r--src/view/com/profile/ProfileHeader.tsx105
-rw-r--r--src/view/com/util/ErrorBoundary.tsx13
-rw-r--r--src/view/com/util/Link.tsx169
-rw-r--r--src/view/com/util/LoadLatestBtn.web.tsx13
-rw-r--r--src/view/com/util/PostEmbeds/QuoteEmbed.tsx1
-rw-r--r--src/view/com/util/PostMeta.tsx95
-rw-r--r--src/view/com/util/PostMuted.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx78
-rw-r--r--src/view/com/util/UserBanner.tsx28
-rw-r--r--src/view/com/util/UserInfoText.tsx26
-rw-r--r--src/view/com/util/ViewHeader.tsx92
-rw-r--r--src/view/com/util/Views.web.tsx7
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx10
-rw-r--r--src/view/com/util/forms/RadioButton.tsx10
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx12
-rw-r--r--src/view/routes.ts91
-rw-r--r--src/view/screens/Contacts.tsx88
-rw-r--r--src/view/screens/Debug.tsx6
-rw-r--r--src/view/screens/Home.tsx68
-rw-r--r--src/view/screens/Log.tsx22
-rw-r--r--src/view/screens/NotFound.tsx48
-rw-r--r--src/view/screens/Notifications.tsx40
-rw-r--r--src/view/screens/PostDownvotedBy.tsx27
-rw-r--r--src/view/screens/PostRepostedBy.tsx19
-rw-r--r--src/view/screens/PostThread.tsx78
-rw-r--r--src/view/screens/PostUpvotedBy.tsx20
-rw-r--r--src/view/screens/Profile.tsx57
-rw-r--r--src/view/screens/ProfileFollowers.tsx19
-rw-r--r--src/view/screens/ProfileFollows.tsx19
-rw-r--r--src/view/screens/Search.tsx53
-rw-r--r--src/view/screens/Search.web.tsx28
-rw-r--r--src/view/screens/Settings.tsx54
-rw-r--r--src/view/shell/BottomBar.tsx (renamed from src/view/shell/mobile/BottomBar.tsx)90
-rw-r--r--src/view/shell/Composer.tsx (renamed from src/view/shell/mobile/Composer.tsx)5
-rw-r--r--src/view/shell/Composer.web.tsx (renamed from src/view/shell/web/Composer.tsx)11
-rw-r--r--src/view/shell/Drawer.tsx386
-rw-r--r--src/view/shell/desktop/LeftNav.tsx254
-rw-r--r--src/view/shell/desktop/RightNav.tsx46
-rw-r--r--src/view/shell/desktop/Search.tsx (renamed from src/view/shell/web/DesktopSearch.tsx)26
-rw-r--r--src/view/shell/index.tsx139
-rw-r--r--src/view/shell/index.web.tsx113
-rw-r--r--src/view/shell/mobile/Menu.tsx354
-rw-r--r--src/view/shell/mobile/index.tsx335
-rw-r--r--src/view/shell/web/DesktopHeader.tsx222
-rw-r--r--src/view/shell/web/index.tsx150
103 files changed, 3489 insertions, 3423 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 8bb204923..fcd6e787b 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -2,18 +2,17 @@ import 'react-native-url-polyfill/auto'
 import React, {useState, useEffect} from 'react'
 import {Linking} from 'react-native'
 import {RootSiblingParent} from 'react-native-root-siblings'
-import {GestureHandlerRootView} from 'react-native-gesture-handler'
 import SplashScreen from 'react-native-splash-screen'
 import {SafeAreaProvider} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
 import {ThemeProvider} from 'lib/ThemeContext'
 import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
-import {MobileShell} from './view/shell/mobile'
-import {s} from 'lib/styles'
+import {Shell} from './view/shell'
 import * as notifee from 'lib/notifee'
 import * as analytics from 'lib/analytics'
 import * as Toast from './view/com/util/Toast'
+import {handleLink} from './Navigation'
 
 const App = observer(() => {
   const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@@ -31,11 +30,11 @@ const App = observer(() => {
       store.hackCheckIfUpgradeNeeded()
       Linking.getInitialURL().then((url: string | null) => {
         if (url) {
-          store.nav.handleLink(url)
+          handleLink(url)
         }
       })
       Linking.addEventListener('url', ({url}) => {
-        store.nav.handleLink(url)
+        handleLink(url)
       })
       store.onSessionDropped(() => {
         Toast.show('Sorry! Your session expired. Please log in again.')
@@ -48,19 +47,17 @@ const App = observer(() => {
     return null
   }
   return (
-    <GestureHandlerRootView style={s.h100pct}>
-      <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
-        <RootSiblingParent>
-          <analytics.Provider>
-            <RootStoreProvider value={rootStore}>
-              <SafeAreaProvider>
-                <MobileShell />
-              </SafeAreaProvider>
-            </RootStoreProvider>
-          </analytics.Provider>
-        </RootSiblingParent>
-      </ThemeProvider>
-    </GestureHandlerRootView>
+    <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
+      <RootSiblingParent>
+        <analytics.Provider>
+          <RootStoreProvider value={rootStore}>
+            <SafeAreaProvider>
+              <Shell />
+            </SafeAreaProvider>
+          </RootStoreProvider>
+        </analytics.Provider>
+      </RootSiblingParent>
+    </ThemeProvider>
   )
 })
 
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 84d3b6cd6..0bfa909be 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -1,9 +1,9 @@
 import React, {useState, useEffect} from 'react'
 import {SafeAreaProvider} from 'react-native-safe-area-context'
-import {getInitialURL} from 'platform/urls'
+import {RootSiblingParent} from 'react-native-root-siblings'
 import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
-import {WebShell} from './view/shell/web'
+import {Shell} from './view/shell/index'
 import {ToastContainer} from './view/com/util/Toast.web'
 
 function App() {
@@ -16,12 +16,6 @@ function App() {
     view.setup()
     setupState().then(store => {
       setRootStore(store)
-      store.nav.bindWebNavigation()
-      getInitialURL().then(url => {
-        if (url) {
-          store.nav.handleLink(url)
-        }
-      })
     })
   }, [])
 
@@ -31,12 +25,14 @@ function App() {
   }
 
   return (
-    <RootStoreProvider value={rootStore}>
-      <SafeAreaProvider>
-        <WebShell />
-      </SafeAreaProvider>
-      <ToastContainer />
-    </RootStoreProvider>
+    <RootSiblingParent>
+      <RootStoreProvider value={rootStore}>
+        <SafeAreaProvider>
+          <Shell />
+        </SafeAreaProvider>
+        <ToastContainer />
+      </RootStoreProvider>
+    </RootSiblingParent>
   )
 }
 
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
new file mode 100644
index 000000000..22d8d8b21
--- /dev/null
+++ b/src/Navigation.tsx
@@ -0,0 +1,287 @@
+import * as React from 'react'
+import {StyleSheet} from 'react-native'
+import {
+  NavigationContainer,
+  createNavigationContainerRef,
+  StackActions,
+} from '@react-navigation/native'
+import {createNativeStackNavigator} from '@react-navigation/native-stack'
+import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
+import {
+  HomeTabNavigatorParams,
+  SearchTabNavigatorParams,
+  NotificationsTabNavigatorParams,
+  FlatNavigatorParams,
+  AllNavigatorParams,
+} from 'lib/routes/types'
+import {BottomBar} from './view/shell/BottomBar'
+import {buildStateObject} from 'lib/routes/helpers'
+import {State, RouteParams} from 'lib/routes/types'
+import {colors} from 'lib/styles'
+import {isNative} from 'platform/detection'
+import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+import {router} from './routes'
+
+import {HomeScreen} from './view/screens/Home'
+import {SearchScreen} from './view/screens/Search'
+import {NotificationsScreen} from './view/screens/Notifications'
+import {NotFoundScreen} from './view/screens/NotFound'
+import {SettingsScreen} from './view/screens/Settings'
+import {ProfileScreen} from './view/screens/Profile'
+import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
+import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
+import {PostThreadScreen} from './view/screens/PostThread'
+import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy'
+import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
+import {DebugScreen} from './view/screens/Debug'
+import {LogScreen} from './view/screens/Log'
+
+const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
+
+const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
+const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
+const NotificationsTab =
+  createNativeStackNavigator<NotificationsTabNavigatorParams>()
+const Flat = createNativeStackNavigator<FlatNavigatorParams>()
+const Tab = createBottomTabNavigator()
+
+/**
+ * These "common screens" are reused across stacks.
+ */
+function commonScreens(Stack: typeof HomeTab) {
+  return (
+    <>
+      <Stack.Screen name="NotFound" component={NotFoundScreen} />
+      <Stack.Screen name="Settings" component={SettingsScreen} />
+      <Stack.Screen name="Profile" component={ProfileScreen} />
+      <Stack.Screen
+        name="ProfileFollowers"
+        component={ProfileFollowersScreen}
+      />
+      <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
+      <Stack.Screen name="PostThread" component={PostThreadScreen} />
+      <Stack.Screen name="PostUpvotedBy" component={PostUpvotedByScreen} />
+      <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
+      <Stack.Screen name="Debug" component={DebugScreen} />
+      <Stack.Screen name="Log" component={LogScreen} />
+    </>
+  )
+}
+
+/**
+ * The TabsNavigator is used by native mobile to represent the routes
+ * in 3 distinct tab-stacks with a different root screen on each.
+ */
+function TabsNavigator() {
+  const tabBar = React.useCallback(props => <BottomBar {...props} />, [])
+  return (
+    <Tab.Navigator
+      initialRouteName="HomeTab"
+      backBehavior="initialRoute"
+      screenOptions={{headerShown: false}}
+      tabBar={tabBar}>
+      <Tab.Screen name="HomeTab" component={HomeTabNavigator} />
+      <Tab.Screen
+        name="NotificationsTab"
+        component={NotificationsTabNavigator}
+      />
+      <Tab.Screen name="SearchTab" component={SearchTabNavigator} />
+    </Tab.Navigator>
+  )
+}
+
+function HomeTabNavigator() {
+  const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+  return (
+    <HomeTab.Navigator
+      screenOptions={{
+        gestureEnabled: true,
+        fullScreenGestureEnabled: true,
+        headerShown: false,
+        animationDuration: 250,
+        contentStyle,
+      }}>
+      <HomeTab.Screen name="Home" component={HomeScreen} />
+      {commonScreens(HomeTab)}
+    </HomeTab.Navigator>
+  )
+}
+
+function SearchTabNavigator() {
+  const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+  return (
+    <SearchTab.Navigator
+      screenOptions={{
+        gestureEnabled: true,
+        fullScreenGestureEnabled: true,
+        headerShown: false,
+        animationDuration: 250,
+        contentStyle,
+      }}>
+      <SearchTab.Screen name="Search" component={SearchScreen} />
+      {commonScreens(SearchTab as typeof HomeTab)}
+    </SearchTab.Navigator>
+  )
+}
+
+function NotificationsTabNavigator() {
+  const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+  return (
+    <NotificationsTab.Navigator
+      screenOptions={{
+        gestureEnabled: true,
+        fullScreenGestureEnabled: true,
+        headerShown: false,
+        animationDuration: 250,
+        contentStyle,
+      }}>
+      <NotificationsTab.Screen
+        name="Notifications"
+        component={NotificationsScreen}
+      />
+      {commonScreens(NotificationsTab as typeof HomeTab)}
+    </NotificationsTab.Navigator>
+  )
+}
+
+/**
+ * The FlatNavigator is used by Web to represent the routes
+ * in a single ("flat") stack.
+ */
+function FlatNavigator() {
+  return (
+    <Flat.Navigator
+      screenOptions={{
+        gestureEnabled: true,
+        fullScreenGestureEnabled: true,
+        headerShown: false,
+        animationDuration: 250,
+        contentStyle: {backgroundColor: 'white'},
+      }}>
+      <Flat.Screen name="Home" component={HomeScreen} />
+      <Flat.Screen name="Search" component={SearchScreen} />
+      <Flat.Screen name="Notifications" component={NotificationsScreen} />
+      {commonScreens(Flat as typeof HomeTab)}
+    </Flat.Navigator>
+  )
+}
+
+/**
+ * The RoutesContainer should wrap all components which need access
+ * to the navigation context.
+ */
+
+const LINKING = {
+  prefixes: ['bsky://', 'https://bsky.app'],
+
+  getPathFromState(state: State) {
+    // find the current node in the navigation tree
+    let node = state.routes[state.index || 0]
+    while (node.state?.routes && typeof node.state?.index === 'number') {
+      node = node.state?.routes[node.state?.index]
+    }
+
+    // build the path
+    const route = router.matchName(node.name)
+    if (typeof route === 'undefined') {
+      return '/' // default to home
+    }
+    return route.build((node.params || {}) as RouteParams)
+  },
+
+  getStateFromPath(path: string) {
+    const [name, params] = router.matchPath(path)
+    if (isNative) {
+      if (name === 'Search') {
+        return buildStateObject('SearchTab', 'Search', params)
+      }
+      if (name === 'Notifications') {
+        return buildStateObject('NotificationsTab', 'Notifications', params)
+      }
+      return buildStateObject('HomeTab', name, params)
+    } else {
+      return buildStateObject('Flat', name, params)
+    }
+  },
+}
+
+function RoutesContainer({children}: React.PropsWithChildren<{}>) {
+  return (
+    <NavigationContainer ref={navigationRef} linking={LINKING}>
+      {children}
+    </NavigationContainer>
+  )
+}
+
+/**
+ * These helpers can be used from outside of the RoutesContainer
+ * (eg in the state models).
+ */
+
+function navigate<K extends keyof AllNavigatorParams>(
+  name: K,
+  params?: AllNavigatorParams[K],
+) {
+  if (navigationRef.isReady()) {
+    // @ts-ignore I dont know what would make typescript happy but I have a life -prf
+    navigationRef.navigate(name, params)
+  }
+}
+
+function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
+  if (navigationRef.isReady()) {
+    navigate(tabName)
+    navigationRef.dispatch(StackActions.popToTop())
+  }
+}
+
+function handleLink(url: string) {
+  let path
+  if (url.startsWith('/')) {
+    path = url
+  } else if (url.startsWith('http')) {
+    try {
+      path = new URL(url).pathname
+    } catch (e) {
+      console.error('Invalid url', url, e)
+      return
+    }
+  } else {
+    console.error('Invalid url', url)
+    return
+  }
+
+  const [name, params] = router.matchPath(path)
+  if (isNative) {
+    if (name === 'Search') {
+      resetToTab('SearchTab')
+    } else if (name === 'Notifications') {
+      resetToTab('NotificationsTab')
+    } else {
+      resetToTab('HomeTab')
+      // @ts-ignore matchPath doesnt give us type-checked output -prf
+      navigate(name, params)
+    }
+  } else {
+    // @ts-ignore matchPath doesnt give us type-checked output -prf
+    navigate(name, params)
+  }
+}
+
+const styles = StyleSheet.create({
+  bgDark: {
+    backgroundColor: colors.black,
+  },
+  bgLight: {
+    backgroundColor: colors.gray1,
+  },
+})
+
+export {
+  navigate,
+  resetToTab,
+  handleLink,
+  TabsNavigator,
+  FlatNavigator,
+  RoutesContainer,
+}
diff --git a/src/app.json b/src/app.json
deleted file mode 100644
index 1f9e391e0..000000000
--- a/src/app.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "name": "xyz.blueskyweb.app",
-  "displayName": "Bluesky"
-}
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
deleted file mode 100644
index 45a06f40a..000000000
--- a/src/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * @format
- */
-
-import {AppRegistry} from 'react-native'
-import App from './App'
-
-AppRegistry.registerComponent('App', () => App)
-
-AppRegistry.runApplication('App', {
-  rootTag: document.getElementById('root'),
-})
diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx
index 5358a8682..725dd2328 100644
--- a/src/lib/analytics.tsx
+++ b/src/lib/analytics.tsx
@@ -16,7 +16,7 @@ export function init(store: RootStoreModel) {
   // this method is a copy of segment's own lifecycle event tracking
   // we handle it manually to ensure that it never fires while the app is backgrounded
   // -prf
-  segmentClient.onContextLoaded(() => {
+  segmentClient.isReady.onChange(() => {
     if (AppState.currentState !== 'active') {
       store.log.debug('Prevented a metrics ping while the app was backgrounded')
       return
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 3b8af44e8..85eca4a61 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -117,7 +117,9 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     if (opts.extLink.localThumb) {
       opts.onStateChange?.('Uploading link thumbnail...')
       let encoding
-      if (opts.extLink.localThumb.path.endsWith('.png')) {
+      if (opts.extLink.localThumb.mime) {
+        encoding = opts.extLink.localThumb.mime
+      } else if (opts.extLink.localThumb.path.endsWith('.png')) {
         encoding = 'image/png'
       } else if (
         opts.extLink.localThumb.path.endsWith('.jpeg') ||
diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts
index d7f4a7287..d7ef9a05e 100644
--- a/src/lib/assets.native.ts
+++ b/src/lib/assets.native.ts
@@ -1,5 +1,5 @@
 import {ImageRequireSource} from 'react-native'
 
-export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg')
-export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg')
-export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png')
+export const DEF_AVATAR: ImageRequireSource = require('../../assets/default-avatar.jpg')
+export const TABS_EXPLAINER: ImageRequireSource = require('../../assets/tabs-explainer.jpg')
+export const CLOUD_SPLASH: ImageRequireSource = require('../../assets/cloud-splash.png')
diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts
index 155230e5d..28b650b6f 100644
--- a/src/lib/build-flags.ts
+++ b/src/lib/build-flags.ts
@@ -1,2 +1 @@
 export const LOGIN_INCLUDE_DEV_SERVERS = true
-export const TABS_ENABLED = false
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 31947cd8f..ef4bb0f08 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -166,5 +166,3 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
 export const POST_IMG_MAX_WIDTH = 2000
 export const POST_IMG_MAX_HEIGHT = 2000
 export const POST_IMG_MAX_SIZE = 1000000
-
-export const DESKTOP_HEADER_HEIGHT = 57
diff --git a/src/lib/hooks/useColorSchemeStyle.ts b/src/lib/hooks/useColorSchemeStyle.ts
index 61e3d7cc9..18c48b961 100644
--- a/src/lib/hooks/useColorSchemeStyle.ts
+++ b/src/lib/hooks/useColorSchemeStyle.ts
@@ -1,6 +1,6 @@
-import {useColorScheme} from 'react-native'
+import {useTheme} from 'lib/ThemeContext'
 
 export function useColorSchemeStyle(lightStyle: any, darkStyle: any) {
-  const colorScheme = useColorScheme()
+  const colorScheme = useTheme().colorScheme
   return colorScheme === 'dark' ? darkStyle : lightStyle
 }
diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts
new file mode 100644
index 000000000..36a92ac32
--- /dev/null
+++ b/src/lib/hooks/usePermissions.ts
@@ -0,0 +1,50 @@
+import {Alert} from 'react-native'
+import {Camera} from 'expo-camera'
+import * as MediaLibrary from 'expo-media-library'
+import {Linking} from 'react-native'
+
+const openSettings = () => {
+  Linking.openURL('app-settings:')
+}
+
+const openPermissionAlert = (perm: string) => {
+  Alert.alert(
+    'Permission needed',
+    `Bluesky does not have permission to access your ${perm}.`,
+    [
+      {
+        text: 'Cancel',
+        style: 'cancel',
+      },
+      {text: 'Open Settings', onPress: () => openSettings()},
+    ],
+  )
+}
+
+export function usePhotoLibraryPermission() {
+  const [mediaLibraryPermissions] = MediaLibrary.usePermissions()
+  const requestPhotoAccessIfNeeded = async () => {
+    if (mediaLibraryPermissions?.status === 'granted') {
+      return true
+    } else {
+      openPermissionAlert('photo library')
+      return false
+    }
+  }
+  return {requestPhotoAccessIfNeeded}
+}
+
+export function useCameraPermission() {
+  const [cameraPermissionStatus] = Camera.useCameraPermissions()
+
+  const requestCameraAccessIfNeeded = async () => {
+    if (cameraPermissionStatus?.granted) {
+      return true
+    } else {
+      openPermissionAlert('camera')
+      return false
+    }
+  }
+
+  return {requestCameraAccessIfNeeded}
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index f82ea2602..e194e7a87 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -73,12 +73,10 @@ export function HomeIconSolid({
   style,
   size,
   strokeWidth = 4,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -89,11 +87,6 @@ export function HomeIconSolid({
       style={style}>
       <Path
         fill="currentColor"
-        stroke="none"
-        opacity={fillOpacity}
-        d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
-      />
-      <Path
         strokeWidth={strokeWidth}
         d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
       />
@@ -158,12 +151,10 @@ export function MagnifyingGlassIcon2Solid({
   style,
   size,
   strokeWidth = 2,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -181,7 +172,6 @@ export function MagnifyingGlassIcon2Solid({
         ry="7"
         stroke="none"
         fill="currentColor"
-        opacity={fillOpacity}
       />
       <Ellipse cx="12" cy="11" rx="9" ry="9" />
       <Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
@@ -219,12 +209,10 @@ export function BellIconSolid({
   style,
   size,
   strokeWidth = 1.5,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -237,10 +225,7 @@ export function BellIconSolid({
       <Path
         d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z"
         fill="currentColor"
-        stroke="none"
-        opacity={fillOpacity}
       />
-      <Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" />
       <Line x1="9" y1="22" x2="15" y2="22" />
     </Svg>
   )
@@ -278,6 +263,34 @@ export function CogIcon({
   )
 }
 
+export function CogIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size || 32}
+      height={size || 32}
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M 9.594 3.94 C 9.684 3.398 10.154 3 10.704 3 L 13.297 3 C 13.847 3 14.317 3.398 14.407 3.94 L 14.62 5.221 C 14.683 5.595 14.933 5.907 15.265 6.091 C 15.339 6.131 15.412 6.174 15.485 6.218 C 15.809 6.414 16.205 6.475 16.56 6.342 L 17.777 5.886 C 18.292 5.692 18.872 5.9 19.147 6.376 L 20.443 8.623 C 20.718 9.099 20.608 9.705 20.183 10.054 L 19.18 10.881 C 18.887 11.121 18.742 11.494 18.749 11.873 C 18.751 11.958 18.751 12.043 18.749 12.128 C 18.742 12.506 18.887 12.878 19.179 13.118 L 20.184 13.946 C 20.608 14.296 20.718 14.9 20.444 15.376 L 19.146 17.623 C 18.871 18.099 18.292 18.307 17.777 18.114 L 16.56 17.658 C 16.205 17.525 15.81 17.586 15.484 17.782 C 15.412 17.826 15.338 17.869 15.264 17.91 C 14.933 18.093 14.683 18.405 14.62 18.779 L 14.407 20.059 C 14.317 20.602 13.847 21 13.297 21 L 10.703 21 C 10.153 21 9.683 20.602 9.593 20.06 L 9.38 18.779 C 9.318 18.405 9.068 18.093 8.736 17.909 C 8.662 17.868 8.589 17.826 8.516 17.782 C 8.191 17.586 7.796 17.525 7.44 17.658 L 6.223 18.114 C 5.708 18.307 5.129 18.1 4.854 17.624 L 3.557 15.377 C 3.282 14.901 3.392 14.295 3.817 13.946 L 4.821 13.119 C 5.113 12.879 5.258 12.506 5.251 12.127 C 5.249 12.042 5.249 11.957 5.251 11.872 C 5.258 11.494 5.113 11.122 4.821 10.882 L 3.817 10.054 C 3.393 9.705 3.283 9.1 3.557 8.624 L 4.854 6.377 C 5.129 5.9 5.709 5.692 6.224 5.886 L 7.44 6.342 C 7.796 6.475 8.191 6.414 8.516 6.218 C 8.588 6.174 8.662 6.131 8.736 6.09 C 9.068 5.907 9.318 5.595 9.38 5.221 Z M 13.5 9.402 C 11.5 8.247 9 9.691 9 12 C 9 13.072 9.572 14.062 10.5 14.598 C 12.5 15.753 15 14.309 15 12 C 15 10.928 14.428 9.938 13.5 9.402 Z"
+        fill="currentColor"
+      />
+    </Svg>
+  )
+}
+
 // Copyright (c) 2020 Refactoring UI Inc.
 // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
 export function MoonIcon({
@@ -336,6 +349,45 @@ export function UserIcon({
   )
 }
 
+export function UserIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size || 32}
+      height={size || 32}
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="currentColor"
+        d="M 15 9.75 C 15 12.059 12.5 13.503 10.5 12.348 C 9.572 11.812 9 10.822 9 9.75 C 9 7.441 11.5 5.997 13.5 7.152 C 14.428 7.688 15 8.678 15 9.75 Z"
+      />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="currentColor"
+        d="M 17.982 18.725 C 16.565 16.849 14.35 15.748 12 15.75 C 9.65 15.748 7.435 16.849 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
+      />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M 17.981 18.725 C 23.158 14.12 21.409 5.639 14.833 3.458 C 8.257 1.277 1.786 7.033 3.185 13.818 C 3.576 15.716 4.57 17.437 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
+      />
+    </Svg>
+  )
+}
+
 // Copyright (c) 2020 Refactoring UI Inc.
 // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
 export function UserGroupIcon({
@@ -674,6 +726,7 @@ export function ComposeIcon2({
     <Svg
       viewBox="0 0 24 24"
       stroke="currentColor"
+      fill="none"
       width={size || 24}
       height={size || 24}
       style={style}>
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index c9c2ed31a..0d8e8c69b 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,19 +1,20 @@
 import {LikelyType, LinkMeta} from './link-meta'
-import {match as matchRoute} from 'view/routes'
+// import {match as matchRoute} from 'view/routes'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 import {RootStoreModel} from 'state/index'
 import {PostThreadViewModel} from 'state/models/post-thread-view'
 import {ComposerOptsQuote} from 'state/models/shell-ui'
 
-import {Home} from 'view/screens/Home'
-import {Search} from 'view/screens/Search'
-import {Notifications} from 'view/screens/Notifications'
-import {PostThread} from 'view/screens/PostThread'
-import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
-import {PostRepostedBy} from 'view/screens/PostRepostedBy'
-import {Profile} from 'view/screens/Profile'
-import {ProfileFollowers} from 'view/screens/ProfileFollowers'
-import {ProfileFollows} from 'view/screens/ProfileFollows'
+// TODO
+// import {Home} from 'view/screens/Home'
+// import {Search} from 'view/screens/Search'
+// import {Notifications} from 'view/screens/Notifications'
+// import {PostThread} from 'view/screens/PostThread'
+// import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
+// import {PostRepostedBy} from 'view/screens/PostRepostedBy'
+// import {Profile} from 'view/screens/Profile'
+// import {ProfileFollowers} from 'view/screens/ProfileFollowers'
+// import {ProfileFollows} from 'view/screens/ProfileFollows'
 
 // NOTE
 // this is a hack around the lack of hosted social metadata
@@ -24,77 +25,77 @@ export async function extractBskyMeta(
   url: string,
 ): Promise<LinkMeta> {
   url = convertBskyAppUrlIfNeeded(url)
-  const route = matchRoute(url)
+  // const route = matchRoute(url)
   let meta: LinkMeta = {
     likelyType: LikelyType.AtpData,
     url,
-    title: route.defaultTitle,
+    // title: route.defaultTitle,
   }
 
-  if (route.Com === Home) {
-    meta = {
-      ...meta,
-      title: 'Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (route.Com === Search) {
-    meta = {
-      ...meta,
-      title: 'Search - Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (route.Com === Notifications) {
-    meta = {
-      ...meta,
-      title: 'Notifications - Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (
-    route.Com === PostThread ||
-    route.Com === PostUpvotedBy ||
-    route.Com === PostRepostedBy
-  ) {
-    // post and post-related screens
-    const threadUri = makeRecordUri(
-      route.params.name,
-      'app.bsky.feed.post',
-      route.params.rkey,
-    )
-    const threadView = new PostThreadViewModel(store, {
-      uri: threadUri,
-      depth: 0,
-    })
-    await threadView.setup().catch(_err => undefined)
-    const title = [
-      route.Com === PostUpvotedBy
-        ? 'Likes on a post by'
-        : route.Com === PostRepostedBy
-        ? 'Reposts of a post by'
-        : 'Post by',
-      threadView.thread?.post.author.displayName ||
-        threadView.thread?.post.author.handle ||
-        'a bluesky user',
-    ].join(' ')
-    meta = {
-      ...meta,
-      title,
-      description: threadView.thread?.postRecord?.text,
-    }
-  } else if (
-    route.Com === Profile ||
-    route.Com === ProfileFollowers ||
-    route.Com === ProfileFollows
-  ) {
-    // profile and profile-related screens
-    const profile = await store.profiles.getProfile(route.params.name)
-    if (profile?.data) {
-      meta = {
-        ...meta,
-        title: profile.data.displayName || profile.data.handle,
-        description: profile.data.description,
-      }
-    }
-  }
+  // if (route.Com === Home) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (route.Com === Search) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Search - Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (route.Com === Notifications) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Notifications - Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (
+  //   route.Com === PostThread ||
+  //   route.Com === PostUpvotedBy ||
+  //   route.Com === PostRepostedBy
+  // ) {
+  //   // post and post-related screens
+  //   const threadUri = makeRecordUri(
+  //     route.params.name,
+  //     'app.bsky.feed.post',
+  //     route.params.rkey,
+  //   )
+  //   const threadView = new PostThreadViewModel(store, {
+  //     uri: threadUri,
+  //     depth: 0,
+  //   })
+  //   await threadView.setup().catch(_err => undefined)
+  //   const title = [
+  //     route.Com === PostUpvotedBy
+  //       ? 'Likes on a post by'
+  //       : route.Com === PostRepostedBy
+  //       ? 'Reposts of a post by'
+  //       : 'Post by',
+  //     threadView.thread?.post.author.displayName ||
+  //       threadView.thread?.post.author.handle ||
+  //       'a bluesky user',
+  //   ].join(' ')
+  //   meta = {
+  //     ...meta,
+  //     title,
+  //     description: threadView.thread?.postRecord?.text,
+  //   }
+  // } else if (
+  //   route.Com === Profile ||
+  //   route.Com === ProfileFollowers ||
+  //   route.Com === ProfileFollows
+  // ) {
+  //   // profile and profile-related screens
+  //   const profile = await store.profiles.getProfile(route.params.name)
+  //   if (profile?.data) {
+  //     meta = {
+  //       ...meta,
+  //       title: profile.data.displayName || profile.data.handle,
+  //       description: profile.data.description,
+  //     }
+  //   }
+  // }
 
   return meta
 }
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index e617d01af..cd0bb3bc9 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,5 +1,6 @@
 // import {Share} from 'react-native'
 // import * as Toast from 'view/com/util/Toast'
+import {extractDataUriMime, getDataUriSize} from './util'
 
 export interface DownloadAndResizeOpts {
   uri: string
@@ -18,9 +19,15 @@ export interface Image {
   height: number
 }
 
-export async function downloadAndResize(_opts: DownloadAndResizeOpts) {
-  // TODO
-  throw new Error('TODO')
+export async function downloadAndResize(opts: DownloadAndResizeOpts) {
+  const controller = new AbortController()
+  const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
+  const res = await fetch(opts.uri)
+  const resBody = await res.blob()
+  clearTimeout(to)
+
+  const dataUri = await blobToDataUri(resBody)
+  return await resize(dataUri, opts)
 }
 
 export interface ResizeOpts {
@@ -31,11 +38,18 @@ export interface ResizeOpts {
 }
 
 export async function resize(
-  _localUri: string,
+  dataUri: string,
   _opts: ResizeOpts,
 ): Promise<Image> {
-  // TODO
-  throw new Error('TODO')
+  const dim = await getImageDim(dataUri)
+  // TODO -- need to resize
+  return {
+    path: dataUri,
+    mime: extractDataUriMime(dataUri),
+    size: getDataUriSize(dataUri),
+    width: dim.width,
+    height: dim.height,
+  }
 }
 
 export async function compressIfNeeded(
@@ -86,3 +100,18 @@ export async function getImageDim(path: string): Promise<Dim> {
   await promise
   return {width: img.width, height: img.height}
 }
+
+function blobToDataUri(blob: Blob): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onloadend = () => {
+      if (typeof reader.result === 'string') {
+        resolve(reader.result)
+      } else {
+        reject(new Error('Failed to read blob'))
+      }
+    }
+    reader.onerror = reject
+    reader.readAsDataURL(blob)
+  })
+}
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 746feaedd..43675074e 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -10,6 +10,7 @@ import {
   compressIfNeeded,
   moveToPremanantPath,
 } from 'lib/media/manip'
+import {extractDataUriMime} from './util'
 
 interface PickedFile {
   uri: string
@@ -138,7 +139,3 @@ function selectFile(opts: PickerOpts): Promise<PickedFile> {
     input.click()
   })
 }
-
-function extractDataUriMime(uri: string): string {
-  return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
-}
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
new file mode 100644
index 000000000..a27c71d82
--- /dev/null
+++ b/src/lib/media/util.ts
@@ -0,0 +1,7 @@
+export function extractDataUriMime(uri: string): string {
+  return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
+}
+
+export function getDataUriSize(uri: string): number {
+  return Math.round((uri.length * 3) / 4) // very rough estimate
+}
diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts
index fb0afdd60..4baf64050 100644
--- a/src/lib/notifee.ts
+++ b/src/lib/notifee.ts
@@ -1,9 +1,9 @@
 import notifee, {EventType} from '@notifee/react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {RootStoreModel} from 'state/models/root-store'
-import {TabPurpose} from 'state/models/navigation'
 import {NotificationsViewItemModel} from 'state/models/notifications-view'
 import {enforceLen} from 'lib/strings/helpers'
+import {resetToTab} from '../Navigation'
 
 export function init(store: RootStoreModel) {
   store.onUnreadNotifications(count => notifee.setBadgeCount(count))
@@ -16,7 +16,7 @@ export function init(store: RootStoreModel) {
     store.log.debug('Notifee foreground event', {type})
     if (type === EventType.PRESS) {
       store.log.debug('User pressed a notifee, opening notifications')
-      store.nav.switchTo(TabPurpose.Notifs, true)
+      resetToTab('NotificationsTab')
     }
   })
   notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
deleted file mode 100644
index ab2c73ca6..000000000
--- a/src/lib/permissions.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import {Alert} from 'react-native'
-import {
-  check,
-  openSettings,
-  Permission,
-  PermissionStatus,
-  PERMISSIONS,
-  RESULTS,
-} from 'react-native-permissions'
-
-export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY
-export const CAMERA = PERMISSIONS.IOS.CAMERA
-
-/**
- * Returns `true` if the user has granted permission or hasn't made
- * a decision yet. Returns `false` if unavailable or not granted.
- */
-export async function hasAccess(perm: Permission): Promise<boolean> {
-  const status = await check(perm)
-  return isntANo(status)
-}
-
-export async function requestAccessIfNeeded(
-  perm: Permission,
-): Promise<boolean> {
-  if (await hasAccess(perm)) {
-    return true
-  }
-  let permDescription
-  if (perm === PHOTO_LIBRARY) {
-    permDescription = 'photo library'
-  } else if (perm === CAMERA) {
-    permDescription = 'camera'
-  } else {
-    return false
-  }
-  Alert.alert(
-    'Permission needed',
-    `Bluesky does not have permission to access your ${permDescription}.`,
-    [
-      {
-        text: 'Cancel',
-        style: 'cancel',
-      },
-      {text: 'Open Settings', onPress: () => openSettings()},
-    ],
-  )
-  return false
-}
-
-export async function requestPhotoAccessIfNeeded() {
-  return requestAccessIfNeeded(PHOTO_LIBRARY)
-}
-
-export async function requestCameraAccessIfNeeded() {
-  return requestAccessIfNeeded(CAMERA)
-}
-
-function isntANo(status: PermissionStatus): boolean {
-  return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED
-}
diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts
deleted file mode 100644
index 5b69637ed..000000000
--- a/src/lib/permissions.web.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
-At the moment, Web doesn't have any equivalence for these.
-*/
-
-export const PHOTO_LIBRARY = ''
-export const CAMERA = ''
-
-export async function hasAccess(_perm: any): Promise<boolean> {
-  return true
-}
-
-export async function requestAccessIfNeeded(_perm: any): Promise<boolean> {
-  return true
-}
-
-export async function requestPhotoAccessIfNeeded() {
-  return requestAccessIfNeeded(PHOTO_LIBRARY)
-}
-
-export async function requestCameraAccessIfNeeded() {
-  return requestAccessIfNeeded(CAMERA)
-}
diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts
new file mode 100644
index 000000000..be76b9669
--- /dev/null
+++ b/src/lib/routes/helpers.ts
@@ -0,0 +1,77 @@
+import {State, RouteParams} from './types'
+
+export function getCurrentRoute(state: State) {
+  let node = state.routes[state.index || 0]
+  while (node.state?.routes && typeof node.state?.index === 'number') {
+    node = node.state?.routes[node.state?.index]
+  }
+  return node
+}
+
+export function isStateAtTabRoot(state: State | undefined) {
+  if (!state) {
+    // NOTE
+    // if state is not defined it's because init is occuring
+    // and therefore we can safely assume we're at root
+    // -prf
+    return true
+  }
+  const currentRoute = getCurrentRoute(state)
+  return (
+    isTab(currentRoute.name, 'Home') ||
+    isTab(currentRoute.name, 'Search') ||
+    isTab(currentRoute.name, 'Notifications')
+  )
+}
+
+export function isTab(current: string, route: string) {
+  // NOTE
+  // our tab routes can be variously referenced by 3 different names
+  // this helper deals with that weirdness
+  // -prf
+  return (
+    current === route ||
+    current === `${route}Tab` ||
+    current === `${route}Inner`
+  )
+}
+
+export enum TabState {
+  InsideAtRoot,
+  Inside,
+  Outside,
+}
+export function getTabState(state: State | undefined, tab: string): TabState {
+  if (!state) {
+    return TabState.Outside
+  }
+  const currentRoute = getCurrentRoute(state)
+  if (isTab(currentRoute.name, tab)) {
+    return TabState.InsideAtRoot
+  } else if (isTab(state.routes[state.index || 0].name, tab)) {
+    return TabState.Inside
+  }
+  return TabState.Outside
+}
+
+export function buildStateObject(
+  stack: string,
+  route: string,
+  params: RouteParams,
+) {
+  if (stack === 'Flat') {
+    return {
+      routes: [{name: route, params}],
+    }
+  }
+  return {
+    routes: [
+      {
+        name: stack,
+        state: {
+          routes: [{name: route, params}],
+        },
+      },
+    ],
+  }
+}
diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts
new file mode 100644
index 000000000..05e0a63de
--- /dev/null
+++ b/src/lib/routes/router.ts
@@ -0,0 +1,55 @@
+import {RouteParams, Route} from './types'
+
+export class Router {
+  routes: [string, Route][] = []
+  constructor(description: Record<string, string>) {
+    for (const [screen, pattern] of Object.entries(description)) {
+      this.routes.push([screen, createRoute(pattern)])
+    }
+  }
+
+  matchName(name: string): Route | undefined {
+    for (const [screenName, route] of this.routes) {
+      if (screenName === name) {
+        return route
+      }
+    }
+  }
+
+  matchPath(path: string): [string, RouteParams] {
+    let name = 'NotFound'
+    let params: RouteParams = {}
+    for (const [screenName, route] of this.routes) {
+      const res = route.match(path)
+      if (res) {
+        name = screenName
+        params = res.params
+        break
+      }
+    }
+    return [name, params]
+  }
+}
+
+function createRoute(pattern: string): Route {
+  let matcherReInternal = pattern.replace(
+    /:([\w]+)/g,
+    (_m, name) => `(?<${name}>[^/]+)`,
+  )
+  const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i')
+  return {
+    match(path: string) {
+      const res = matcherRe.exec(path)
+      if (res) {
+        return {params: res.groups || {}}
+      }
+      return undefined
+    },
+    build(params: Record<string, string>) {
+      return pattern.replace(
+        /:([\w]+)/g,
+        (_m, name) => params[name] || 'undefined',
+      )
+    },
+  }
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
new file mode 100644
index 000000000..e339a46bf
--- /dev/null
+++ b/src/lib/routes/types.ts
@@ -0,0 +1,61 @@
+import {NavigationState, PartialState} from '@react-navigation/native'
+import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
+
+export type {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+export type CommonNavigatorParams = {
+  NotFound: undefined
+  Settings: undefined
+  Profile: {name: string}
+  ProfileFollowers: {name: string}
+  ProfileFollows: {name: string}
+  PostThread: {name: string; rkey: string}
+  PostUpvotedBy: {name: string; rkey: string}
+  PostRepostedBy: {name: string; rkey: string}
+  Debug: undefined
+  Log: undefined
+}
+
+export type HomeTabNavigatorParams = CommonNavigatorParams & {
+  Home: undefined
+}
+
+export type SearchTabNavigatorParams = CommonNavigatorParams & {
+  Search: undefined
+}
+
+export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
+  Notifications: undefined
+}
+
+export type FlatNavigatorParams = CommonNavigatorParams & {
+  Home: undefined
+  Search: undefined
+  Notifications: undefined
+}
+
+export type AllNavigatorParams = CommonNavigatorParams & {
+  HomeTab: undefined
+  Home: undefined
+  SearchTab: undefined
+  Search: undefined
+  NotificationsTab: undefined
+  Notifications: undefined
+}
+
+// NOTE
+// this isn't strictly correct but it should be close enough
+// a TS wizard might be able to get this 100%
+// -prf
+export type NavigationProp = NativeStackNavigationProp<AllNavigatorParams>
+
+export type State =
+  | NavigationState
+  | Omit<PartialState<NavigationState>, 'stale'>
+
+export type RouteParams = Record<string, string>
+export type MatchResult = {params: RouteParams}
+export type Route = {
+  match: (path: string) => MatchResult | undefined
+  build: (params: RouteParams) => string
+}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index dbce39178..328229f46 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,7 +1,5 @@
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
-import {isDesktopWeb} from 'platform/detection'
-import {DESKTOP_HEADER_HEIGHT} from './constants'
 
 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
 export const colors = {
@@ -161,9 +159,7 @@ export const s = StyleSheet.create({
   // dimensions
   w100pct: {width: '100%'},
   h100pct: {height: '100%'},
-  hContentRegion: isDesktopWeb
-    ? {height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`}
-    : {height: '100%'},
+  hContentRegion: {height: '100%'},
 
   // text align
   textLeft: {textAlign: 'left'},
diff --git a/src/routes.ts b/src/routes.ts
new file mode 100644
index 000000000..5987177ef
--- /dev/null
+++ b/src/routes.ts
@@ -0,0 +1,16 @@
+import {Router} from 'lib/routes/router'
+
+export const router = new Router({
+  Home: '/',
+  Search: '/search',
+  Notifications: '/notifications',
+  Settings: '/settings',
+  Profile: '/profile/:name',
+  ProfileFollowers: '/profile/:name/followers',
+  ProfileFollows: '/profile/:name/follows',
+  PostThread: '/profile/:name/post/:rkey',
+  PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by',
+  PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
+  Debug: '/sys/debug',
+  Log: '/sys/log',
+})
diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts
deleted file mode 100644
index 11af65912..000000000
--- a/src/state/models/navigation.ts
+++ /dev/null
@@ -1,434 +0,0 @@
-import {RootStoreModel} from './root-store'
-import {makeAutoObservable} from 'mobx'
-import {TABS_ENABLED} from 'lib/build-flags'
-import * as analytics from 'lib/analytics'
-import {isNative} from 'platform/detection'
-
-let __id = 0
-function genId() {
-  return String(++__id)
-}
-
-// NOTE
-// this model was originally built for a freeform "tabs" concept like a browser
-// we've since decided to pause that idea and do something more traditional
-// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
-// - Tab 0: The "Default" tab
-// - Tab 1: The "Search" tab
-// - Tab 2: The "Notifications" tab
-// These tabs always retain the first item in their history.
-// -prf
-export enum TabPurpose {
-  Default = 0,
-  Search = 1,
-  Notifs = 2,
-}
-
-export const TabPurposeMainPath: Record<TabPurpose, string> = {
-  [TabPurpose.Default]: '/',
-  [TabPurpose.Search]: '/search',
-  [TabPurpose.Notifs]: '/notifications',
-}
-
-interface HistoryItem {
-  url: string
-  ts: number
-  title?: string
-  id: string
-}
-
-export type HistoryPtr = string // `{tabId}-{historyId}`
-
-export class NavigationTabModel {
-  id = genId()
-  history: HistoryItem[]
-  index = 0
-  isNewTab = false
-
-  constructor(public fixedTabPurpose: TabPurpose) {
-    this.history = [
-      {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
-    ]
-    makeAutoObservable(this, {
-      serialize: false,
-      hydrate: false,
-    })
-  }
-  // accessors
-  // =
-
-  get current() {
-    return this.history[this.index]
-  }
-
-  get canGoBack() {
-    return this.index > 0
-  }
-
-  get canGoForward() {
-    return this.index < this.history.length - 1
-  }
-
-  getBackList(n: number) {
-    const start = Math.max(this.index - n, 0)
-    const end = this.index
-    return this.history.slice(start, end).map((item, i) => ({
-      url: item.url,
-      title: item.title,
-      index: start + i,
-      id: item.id,
-    }))
-  }
-
-  get backTen() {
-    return this.getBackList(10)
-  }
-
-  getForwardList(n: number) {
-    const start = Math.min(this.index + 1, this.history.length)
-    const end = Math.min(this.index + n + 1, this.history.length)
-    return this.history.slice(start, end).map((item, i) => ({
-      url: item.url,
-      title: item.title,
-      index: start + i,
-      id: item.id,
-    }))
-  }
-
-  get forwardTen() {
-    return this.getForwardList(10)
-  }
-
-  // navigation
-  // =
-
-  navigate(url: string, title?: string) {
-    try {
-      const path = url.split('/')[1]
-      analytics.track('Navigation', {
-        path,
-      })
-    } catch (error) {}
-
-    if (this.current?.url === url) {
-      this.refresh()
-    } else {
-      if (this.index < this.history.length - 1) {
-        this.history.length = this.index + 1
-      }
-      // TEMP ensure the tab has its purpose's main view -prf
-      if (this.history.length < 1) {
-        const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
-        this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
-      }
-      this.history.push({url, title, ts: Date.now(), id: genId()})
-      this.index = this.history.length - 1
-      if (!isNative) {
-        window.history.pushState({hindex: this.index, hurl: url}, '', url)
-      }
-    }
-  }
-
-  refresh() {
-    this.history = [
-      ...this.history.slice(0, this.index),
-      {
-        url: this.current.url,
-        title: this.current.title,
-        ts: Date.now(),
-        id: this.current.id,
-      },
-      ...this.history.slice(this.index + 1),
-    ]
-  }
-
-  goBack() {
-    if (this.canGoBack) {
-      this.index--
-      if (!isNative) {
-        window.history.back()
-      }
-    }
-  }
-
-  // TEMP
-  // a helper to bring the tab back to its base state
-  // -prf
-  fixedTabReset() {
-    this.index = 0
-  }
-
-  goForward() {
-    if (this.canGoForward) {
-      this.index++
-      if (!isNative) {
-        window.history.forward()
-      }
-    }
-  }
-
-  goToIndex(index: number) {
-    if (index >= 0 && index <= this.history.length - 1) {
-      const delta = index - this.index
-      this.index = index
-      if (!isNative) {
-        window.history.go(delta)
-      }
-    }
-  }
-
-  setTitle(id: string, title: string) {
-    this.history = this.history.map(h => {
-      if (h.id === id) {
-        return {...h, title}
-      }
-      return h
-    })
-  }
-
-  setIsNewTab(v: boolean) {
-    this.isNewTab = v
-  }
-
-  // browser only
-  // =
-
-  resetTo(url: string) {
-    this.index = 0
-    this.history.push({url, title: '', ts: Date.now(), id: genId()})
-    this.index = this.history.length - 1
-  }
-
-  // persistence
-  // =
-
-  serialize(): unknown {
-    return {
-      history: this.history,
-      index: this.index,
-    }
-  }
-
-  hydrate(_v: unknown) {
-    // TODO fixme
-    // if (isObj(v)) {
-    //   if (hasProp(v, 'history') && Array.isArray(v.history)) {
-    //     for (const item of v.history) {
-    //       if (
-    //         isObj(item) &&
-    //         hasProp(item, 'url') &&
-    //         typeof item.url === 'string'
-    //       ) {
-    //         let copy: HistoryItem = {
-    //           url: item.url,
-    //           ts:
-    //             hasProp(item, 'ts') && typeof item.ts === 'number'
-    //               ? item.ts
-    //               : Date.now(),
-    //         }
-    //         if (hasProp(item, 'title') && typeof item.title === 'string') {
-    //           copy.title = item.title
-    //         }
-    //         this.history.push(copy)
-    //       }
-    //     }
-    //   }
-    //   if (hasProp(v, 'index') && typeof v.index === 'number') {
-    //     this.index = v.index
-    //   }
-    //   if (this.index >= this.history.length - 1) {
-    //     this.index = this.history.length - 1
-    //   }
-    // }
-  }
-}
-
-export class NavigationModel {
-  tabs: NavigationTabModel[] = isNative
-    ? [
-        new NavigationTabModel(TabPurpose.Default),
-        new NavigationTabModel(TabPurpose.Search),
-        new NavigationTabModel(TabPurpose.Notifs),
-      ]
-    : [new NavigationTabModel(TabPurpose.Default)]
-  tabIndex = 0
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {
-      rootStore: false,
-      serialize: false,
-      hydrate: false,
-    })
-  }
-
-  /**
-   * Used only in the web build to sync with browser history state
-   */
-  bindWebNavigation() {
-    if (!isNative) {
-      window.addEventListener('popstate', e => {
-        const {hindex, hurl} = e.state
-        if (hindex >= 0 && hindex <= this.tab.history.length - 1) {
-          this.tab.index = hindex
-        }
-        if (this.tab.current.url !== hurl) {
-          // desynced because they went back to an old tab session-
-          // do a reset to match that
-          this.tab.resetTo(hurl)
-        }
-
-        // sanity check
-        if (this.tab.current.url !== window.location.pathname) {
-          // state has completely desynced, reload
-          window.location.reload()
-        }
-      })
-    }
-  }
-
-  clear() {
-    this.tabs = isNative
-      ? [
-          new NavigationTabModel(TabPurpose.Default),
-          new NavigationTabModel(TabPurpose.Search),
-          new NavigationTabModel(TabPurpose.Notifs),
-        ]
-      : [new NavigationTabModel(TabPurpose.Default)]
-    this.tabIndex = 0
-  }
-
-  // accessors
-  // =
-
-  get tab() {
-    return this.tabs[this.tabIndex]
-  }
-
-  get tabCount() {
-    return this.tabs.length
-  }
-
-  isCurrentScreen(tabId: string, index: number) {
-    return this.tab.id === tabId && this.tab.index === index
-  }
-
-  // navigation
-  // =
-
-  navigate(url: string, title?: string) {
-    this.rootStore.emitNavigation()
-    this.tab.navigate(url, title)
-  }
-
-  refresh() {
-    this.tab.refresh()
-  }
-
-  setTitle(ptr: HistoryPtr, title: string) {
-    const [tid, hid] = ptr.split('-')
-    this.tabs.find(t => t.id === tid)?.setTitle(hid, title)
-  }
-
-  handleLink(url: string) {
-    let path
-    if (url.startsWith('/')) {
-      path = url
-    } else if (url.startsWith('http')) {
-      try {
-        path = new URL(url).pathname
-      } catch (e) {
-        console.error('Invalid url', url, e)
-        return
-      }
-    } else {
-      console.error('Invalid url', url)
-      return
-    }
-    this.navigate(path)
-  }
-
-  // tab management
-  // =
-
-  // TEMP
-  // fixed tab helper function
-  // -prf
-  switchTo(purpose: TabPurpose, reset: boolean) {
-    this.rootStore.emitNavigation()
-    switch (purpose) {
-      case TabPurpose.Notifs:
-        this.tabIndex = 2
-        break
-      case TabPurpose.Search:
-        this.tabIndex = 1
-        break
-      default:
-        this.tabIndex = 0
-    }
-    if (reset) {
-      this.tab.fixedTabReset()
-    }
-  }
-
-  newTab(url: string, title?: string) {
-    if (!TABS_ENABLED) {
-      return this.navigate(url)
-    }
-    const tab = new NavigationTabModel(TabPurpose.Default)
-    tab.navigate(url, title)
-    tab.isNewTab = true
-    this.tabs.push(tab)
-    this.tabIndex = this.tabs.length - 1
-  }
-
-  setActiveTab(tabIndex: number) {
-    if (!TABS_ENABLED) {
-      return
-    }
-    this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
-  }
-
-  closeTab(tabIndex: number) {
-    if (!TABS_ENABLED) {
-      return
-    }
-    this.tabs = [
-      ...this.tabs.slice(0, tabIndex),
-      ...this.tabs.slice(tabIndex + 1),
-    ]
-    if (this.tabs.length === 0) {
-      this.newTab('/')
-    } else if (this.tabIndex >= this.tabs.length) {
-      this.tabIndex = this.tabs.length - 1
-    }
-  }
-
-  // persistence
-  // =
-
-  serialize(): unknown {
-    return {
-      tabs: this.tabs.map(t => t.serialize()),
-      tabIndex: this.tabIndex,
-    }
-  }
-
-  hydrate(_v: unknown) {
-    // TODO fixme
-    this.clear()
-    /*if (isObj(v)) {
-      if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
-        for (const tab of v.tabs) {
-          const copy = new NavigationTabModel()
-          copy.hydrate(tab)
-          if (copy.history.length) {
-            this.tabs.push(copy)
-          }
-        }
-      }
-      if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
-        this.tabIndex = v.tabIndex
-      }
-    }*/
-  }
-}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 4b62f501e..203dacce8 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -11,12 +11,12 @@ import {z} from 'zod'
 import {isObj, hasProp} from 'lib/type-guards'
 import {LogModel} from './log'
 import {SessionModel} from './session'
-import {NavigationModel} from './navigation'
 import {ShellUiModel} from './shell-ui'
 import {ProfilesViewModel} from './profiles-view'
 import {LinkMetasViewModel} from './link-metas-view'
 import {NotificationsViewItemModel} from './notifications-view'
 import {MeModel} from './me'
+import {resetToTab} from '../../Navigation'
 
 export const appInfo = z.object({
   build: z.string(),
@@ -31,7 +31,6 @@ export class RootStoreModel {
   appInfo?: AppInfo
   log = new LogModel()
   session = new SessionModel(this)
-  nav = new NavigationModel(this)
   shell = new ShellUiModel(this)
   me = new MeModel(this)
   profiles = new ProfilesViewModel(this)
@@ -82,7 +81,6 @@ export class RootStoreModel {
       log: this.log.serialize(),
       session: this.session.serialize(),
       me: this.me.serialize(),
-      nav: this.nav.serialize(),
       shell: this.shell.serialize(),
     }
   }
@@ -101,9 +99,6 @@ export class RootStoreModel {
       if (hasProp(v, 'me')) {
         this.me.hydrate(v.me)
       }
-      if (hasProp(v, 'nav')) {
-        this.nav.hydrate(v.nav)
-      }
       if (hasProp(v, 'session')) {
         this.session.hydrate(v.session)
       }
@@ -144,7 +139,7 @@ export class RootStoreModel {
    */
   async handleSessionDrop() {
     this.log.debug('RootStoreModel:handleSessionDrop')
-    this.nav.clear()
+    resetToTab('HomeTab')
     this.me.clear()
     this.emitSessionDropped()
   }
@@ -155,7 +150,7 @@ export class RootStoreModel {
   clearAllSessionState() {
     this.log.debug('RootStoreModel:clearAllSessionState')
     this.session.clear()
-    this.nav.clear()
+    resetToTab('HomeTab')
     this.me.clear()
   }
 
@@ -203,6 +198,7 @@ export class RootStoreModel {
   }
 
   // the current screen has changed
+  // TODO is this still needed?
   onNavigation(handler: () => void): EmitterSubscription {
     return DeviceEventEmitter.addListener('navigation', handler)
   }
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index 68d9cd3d0..8e4eed6eb 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -108,7 +108,6 @@ export interface ComposerOptsQuote {
   }
 }
 export interface ComposerOpts {
-  imagesOpen?: boolean
   replyTo?: ComposerOptsPostRef
   onPost?: () => void
   quote?: ComposerOptsQuote
@@ -117,7 +116,7 @@ export interface ComposerOpts {
 export class ShellUiModel {
   darkMode = false
   minimalShellMode = false
-  isMainMenuOpen = false
+  isDrawerOpen = false
   isModalActive = false
   activeModals: Modal[] = []
   isLightboxActive = false
@@ -156,8 +155,12 @@ export class ShellUiModel {
     this.minimalShellMode = v
   }
 
-  setMainMenuOpen(v: boolean) {
-    this.isMainMenuOpen = v
+  openDrawer() {
+    this.isDrawerOpen = true
+  }
+
+  closeDrawer() {
+    this.isDrawerOpen = false
   }
 
   openModal(modal: Modal) {
diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts
deleted file mode 100644
index b14e8a6a4..000000000
--- a/src/state/models/user-local-photos.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
-import {makeAutoObservable, runInAction} from 'mobx'
-import {CameraRoll} from '@react-native-camera-roll/camera-roll'
-import {RootStoreModel} from './root-store'
-
-export type {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
-
-export class UserLocalPhotosModel {
-  // state
-  photos: PhotoIdentifier[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {
-      rootStore: false,
-    })
-  }
-
-  async setup() {
-    const r = await CameraRoll.getPhotos({first: 20})
-    runInAction(() => {
-      this.photos = r.edges
-    })
-  }
-}
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/Composer.tsx
index f45c6340d..e9b728d73 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -1,74 +1,47 @@
-import React, {useEffect, useMemo, useRef, useState} from 'react'
+import React, {useEffect, useRef, useState} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
-  NativeSyntheticEvent,
   Platform,
   SafeAreaView,
   ScrollView,
   StyleSheet,
-  TextInputSelectionChangeEventData,
   TouchableOpacity,
   TouchableWithoutFeedback,
   View,
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useAnalytics} from 'lib/analytics'
-import _isEqual from 'lodash.isequal'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
-import {Autocomplete} from './autocomplete/Autocomplete'
 import {ExternalEmbed} from './ExternalEmbed'
 import {Text} from '../util/text/Text'
 import * as Toast from '../util/Toast'
 import {TextInput, TextInputRef} from './text-input/TextInput'
 import {CharProgress} from './char-progress/CharProgress'
-import {TextLink} from '../util/Link'
 import {UserAvatar} from '../util/UserAvatar'
 import {useStores} from 'state/index'
 import * as apilib from 'lib/api/index'
 import {ComposerOpts} from 'state/models/shell-ui'
 import {s, colors, gradients} from 'lib/styles'
 import {cleanError} from 'lib/strings/errors'
-import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
-import {getLinkMeta} from 'lib/link-meta/link-meta'
-import {getPostAsQuote} from 'lib/link-meta/bsky'
-import {getImageDim, downloadAndResize} from 'lib/media/manip'
-import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker'
-import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker'
-import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
-import {isBskyPostUrl} from 'lib/strings/url-helpers'
-import {SelectedPhoto} from './SelectedPhoto'
+import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
+import {OpenCameraBtn} from './photos/OpenCameraBtn'
+import {SelectedPhotos} from './photos/SelectedPhotos'
 import {usePalette} from 'lib/hooks/usePalette'
-import {
-  POST_IMG_MAX_WIDTH,
-  POST_IMG_MAX_HEIGHT,
-  POST_IMG_MAX_SIZE,
-} from 'lib/constants'
-import {isWeb} from 'platform/detection'
 import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed'
+import {useExternalLinkFetch} from './useExternalLinkFetch'
 
 const MAX_TEXT_LENGTH = 256
-const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
-
-interface Selection {
-  start: number
-  end: number
-}
 
 export const ComposePost = observer(function ComposePost({
   replyTo,
-  imagesOpen,
   onPost,
   onClose,
   quote: initQuote,
 }: {
   replyTo?: ComposerOpts['replyTo']
-  imagesOpen?: ComposerOpts['imagesOpen']
   onPost?: ComposerOpts['onPost']
   onClose: () => void
   quote?: ComposerOpts['quote']
@@ -77,7 +50,6 @@ export const ComposePost = observer(function ComposePost({
   const pal = usePalette('default')
   const store = useStores()
   const textInput = useRef<TextInputRef>(null)
-  const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const [isProcessing, setIsProcessing] = useState(false)
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
@@ -85,15 +57,8 @@ export const ComposePost = observer(function ComposePost({
   const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
-  const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
-    undefined,
-  )
-  const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>(
-    new Set(),
-  )
-  const [isSelectingPhotos, setIsSelectingPhotos] = useState(
-    imagesOpen || false,
-  )
+  const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
 
   const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
@@ -106,85 +71,16 @@ export const ComposePost = observer(function ComposePost({
   // is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
   // manually blurring before closing gets around that
   // -prf
-  const hackfixOnClose = () => {
+  const hackfixOnClose = React.useCallback(() => {
     textInput.current?.blur()
     onClose()
-  }
+  }, [textInput, onClose])
 
   // initial setup
   useEffect(() => {
     autocompleteView.setup()
   }, [autocompleteView])
 
-  // external link metadata-fetch flow
-  useEffect(() => {
-    let aborted = false
-    const cleanup = () => {
-      aborted = true
-    }
-    if (!extLink) {
-      return cleanup
-    }
-    if (!extLink.meta) {
-      if (isBskyPostUrl(extLink.uri)) {
-        getPostAsQuote(store, extLink.uri).then(
-          newQuote => {
-            if (aborted) {
-              return
-            }
-            setQuote(newQuote)
-            setExtLink(undefined)
-          },
-          err => {
-            store.log.error('Failed to fetch post for quote embedding', {err})
-            setExtLink(undefined)
-          },
-        )
-      } else {
-        getLinkMeta(store, extLink.uri).then(meta => {
-          if (aborted) {
-            return
-          }
-          setExtLink({
-            uri: extLink.uri,
-            isLoading: !!meta.image,
-            meta,
-          })
-        })
-      }
-      return cleanup
-    }
-    if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
-      downloadAndResize({
-        uri: extLink.meta.image,
-        width: 2000,
-        height: 2000,
-        mode: 'contain',
-        maxSize: 1000000,
-        timeout: 15e3,
-      })
-        .catch(() => undefined)
-        .then(localThumb => {
-          if (aborted) {
-            return
-          }
-          setExtLink({
-            ...extLink,
-            isLoading: false, // done
-            localThumb,
-          })
-        })
-      return cleanup
-    }
-    if (extLink.isLoading) {
-      setExtLink({
-        ...extLink,
-        isLoading: false, // done
-      })
-    }
-    return cleanup
-  }, [store, extLink])
-
   useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
@@ -202,95 +98,36 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [])
 
-  const onPressContainer = () => {
+  const onPressContainer = React.useCallback(() => {
     textInput.current?.focus()
-  }
-  const onPressSelectPhotos = async () => {
-    track('ComposePost:SelectPhotos')
-    if (isWeb) {
-      if (selectedPhotos.length < 4) {
-        const images = await pickImagesFlow(
-          store,
-          4 - selectedPhotos.length,
-          {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-          POST_IMG_MAX_SIZE,
-        )
-        setSelectedPhotos([...selectedPhotos, ...images])
-      }
-    } else {
-      if (isSelectingPhotos) {
-        setIsSelectingPhotos(false)
-      } else if (selectedPhotos.length < 4) {
-        setIsSelectingPhotos(true)
-      }
-    }
-  }
-  const onSelectPhotos = (photos: string[]) => {
-    track('ComposePost:SelectPhotos:Done')
-    setSelectedPhotos(photos)
-    if (photos.length >= 4) {
-      setIsSelectingPhotos(false)
-    }
-  }
-  const onPressAddLinkCard = (uri: string) => {
-    setExtLink({uri, isLoading: true})
-  }
-  const onChangeText = (newText: string) => {
-    setText(newText)
+  }, [textInput])
 
-    const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
-    if (prefix) {
-      autocompleteView.setActive(true)
-      autocompleteView.setPrefix(prefix.value)
-    } else {
-      autocompleteView.setActive(false)
-    }
+  const onSelectPhotos = React.useCallback(
+    (photos: string[]) => {
+      track('Composer:SelectedPhotos')
+      setSelectedPhotos(photos)
+    },
+    [track, setSelectedPhotos],
+  )
 
-    if (!extLink) {
-      const ents = extractEntities(newText)?.filter(ent => ent.type === 'link')
-      const set = new Set(ents ? ents.map(e => e.value) : [])
-      if (!_isEqual(set, suggestedExtLinks)) {
-        setSuggestedExtLinks(set)
-      }
-    }
-  }
-  const onPaste = async (err: string | undefined, uris: string[]) => {
-    if (err) {
-      return setError(cleanError(err))
-    }
-    if (selectedPhotos.length >= 4) {
-      return
-    }
-    const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
-    if (imgUri) {
-      let imgDim
-      try {
-        imgDim = await getImageDim(imgUri)
-      } catch (e) {
-        imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
+  const onPressAddLinkCard = React.useCallback(
+    (uri: string) => {
+      setExtLink({uri, isLoading: true})
+    },
+    [setExtLink],
+  )
+
+  const onPhotoPasted = React.useCallback(
+    async (uri: string) => {
+      if (selectedPhotos.length >= 4) {
+        return
       }
-      const finalImgPath = await cropAndCompressFlow(
-        store,
-        imgUri,
-        imgDim,
-        {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-        POST_IMG_MAX_SIZE,
-      )
-      onSelectPhotos([...selectedPhotos, finalImgPath])
-    }
-  }
-  const onSelectionChange = (
-    evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
-  ) => {
-    // NOTE we track the input selection using a ref to avoid excessive renders -prf
-    textInputSelection.current = evt.nativeEvent.selection
-  }
-  const onSelectAutocompleteItem = (item: string) => {
-    setText(insertMentionAt(text, textInputSelection.current?.start || 0, item))
-    autocompleteView.setActive(false)
-  }
-  const onPressCancel = () => hackfixOnClose()
-  const onPressPublish = async () => {
+      onSelectPhotos([...selectedPhotos, uri])
+    },
+    [selectedPhotos, onSelectPhotos],
+  )
+
+  const onPressPublish = React.useCallback(async () => {
     if (isProcessing) {
       return
     }
@@ -332,7 +169,22 @@ export const ComposePost = observer(function ComposePost({
     onPost?.()
     hackfixOnClose()
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
-  }
+  }, [
+    isProcessing,
+    text,
+    setError,
+    setIsProcessing,
+    replyTo,
+    autocompleteView.knownHandles,
+    extLink,
+    hackfixOnClose,
+    onPost,
+    quote,
+    selectedPhotos,
+    setExtLink,
+    store,
+    track,
+  ])
 
   const canPost = text.length <= MAX_TEXT_LENGTH
 
@@ -346,25 +198,6 @@ export const ComposePost = observer(function ComposePost({
     ? 'Write a comment'
     : "What's up?"
 
-  const textDecorated = useMemo(() => {
-    let i = 0
-    return detectLinkables(text).map(v => {
-      if (typeof v === 'string') {
-        return (
-          <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
-            {v}
-          </Text>
-        )
-      } else {
-        return (
-          <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
-            {v.link}
-          </Text>
-        )
-      }
-    })
-  }, [text, pal.link, pal.text])
-
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -375,7 +208,7 @@ export const ComposePost = observer(function ComposePost({
           <View style={styles.topbar}>
             <TouchableOpacity
               testID="composerCancelButton"
-              onPress={onPressCancel}>
+              onPress={hackfixOnClose}>
               <Text style={[pal.link, s.f18]}>Cancel</Text>
             </TouchableOpacity>
             <View style={s.flex1} />
@@ -423,19 +256,11 @@ export const ComposePost = observer(function ComposePost({
           <ScrollView style={s.flex1}>
             {replyTo ? (
               <View style={[pal.border, styles.replyToLayout]}>
-                <UserAvatar
-                  handle={replyTo.author.handle}
-                  displayName={replyTo.author.displayName}
-                  avatar={replyTo.author.avatar}
-                  size={50}
-                />
+                <UserAvatar avatar={replyTo.author.avatar} size={50} />
                 <View style={styles.replyToPost}>
-                  <TextLink
-                    type="xl-medium"
-                    href={`/profile/${replyTo.author.handle}`}
-                    text={replyTo.author.displayName || replyTo.author.handle}
-                    style={[pal.text]}
-                  />
+                  <Text type="xl-medium" style={[pal.text]}>
+                    {replyTo.author.displayName || replyTo.author.handle}
+                  </Text>
                   <Text type="post-text" style={pal.text} numberOfLines={6}>
                     {replyTo.text}
                   </Text>
@@ -449,26 +274,18 @@ export const ComposePost = observer(function ComposePost({
                 styles.textInputLayout,
                 selectTextInputLayout,
               ]}>
-              <UserAvatar
-                handle={store.me.handle || ''}
-                displayName={store.me.displayName}
-                avatar={store.me.avatar}
-                size={50}
-              />
+              <UserAvatar avatar={store.me.avatar} size={50} />
               <TextInput
-                testID="composerTextInput"
-                innerRef={textInput}
-                onChangeText={(str: string) => onChangeText(str)}
-                onPaste={onPaste}
-                onSelectionChange={onSelectionChange}
+                ref={textInput}
+                text={text}
                 placeholder={selectTextInputPlaceholder}
-                style={[
-                  pal.text,
-                  styles.textInput,
-                  styles.textInputFormatting,
-                ]}>
-                {textDecorated}
-              </TextInput>
+                suggestedLinks={suggestedLinks}
+                autocompleteView={autocompleteView}
+                onTextChanged={setText}
+                onPhotoPasted={onPhotoPasted}
+                onSuggestedLinksChanged={setSuggestedLinks}
+                onError={setError}
+              />
             </View>
 
             {quote ? (
@@ -477,7 +294,7 @@ export const ComposePost = observer(function ComposePost({
               </View>
             ) : undefined}
 
-            <SelectedPhoto
+            <SelectedPhotos
               selectedPhotos={selectedPhotos}
               onSelectPhotos={onSelectPhotos}
             />
@@ -488,17 +305,12 @@ export const ComposePost = observer(function ComposePost({
               />
             )}
           </ScrollView>
-          {isSelectingPhotos && selectedPhotos.length < 4 ? (
-            <PhotoCarouselPicker
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={onSelectPhotos}
-            />
-          ) : !extLink &&
-            selectedPhotos.length === 0 &&
-            suggestedExtLinks.size > 0 &&
-            !quote ? (
+          {!extLink &&
+          selectedPhotos.length === 0 &&
+          suggestedLinks.size > 0 &&
+          !quote ? (
             <View style={s.mb5}>
-              {Array.from(suggestedExtLinks).map(url => (
+              {Array.from(suggestedLinks).map(url => (
                 <TouchableOpacity
                   key={`suggested-${url}`}
                   style={[pal.borderDark, styles.addExtLinkBtn]}
@@ -511,31 +323,19 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : null}
           <View style={[pal.border, styles.bottomBar]}>
-            {quote ? undefined : (
-              <TouchableOpacity
-                testID="composerSelectPhotosButton"
-                onPress={onPressSelectPhotos}
-                style={[s.pl5]}
-                hitSlop={HITSLOP}>
-                <FontAwesomeIcon
-                  icon={['far', 'image']}
-                  style={
-                    (selectedPhotos.length < 4
-                      ? pal.link
-                      : pal.textLight) as FontAwesomeIconStyle
-                  }
-                  size={24}
-                />
-              </TouchableOpacity>
-            )}
+            <SelectPhotoBtn
+              enabled={!quote && selectedPhotos.length < 4}
+              selectedPhotos={selectedPhotos}
+              onSelectPhotos={setSelectedPhotos}
+            />
+            <OpenCameraBtn
+              enabled={!quote && selectedPhotos.length < 4}
+              selectedPhotos={selectedPhotos}
+              onSelectPhotos={setSelectedPhotos}
+            />
             <View style={s.flex1} />
             <CharProgress count={text.length} />
           </View>
-          <Autocomplete
-            active={autocompleteView.isActive}
-            items={autocompleteView.suggestions}
-            onSelect={onSelectAutocompleteItem}
-          />
         </SafeAreaView>
       </TouchableWithoutFeedback>
     </KeyboardAvoidingView>
@@ -597,18 +397,6 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
     paddingTop: 16,
   },
-  textInput: {
-    flex: 1,
-    padding: 5,
-    marginLeft: 8,
-    alignSelf: 'flex-start',
-  },
-  textInputFormatting: {
-    fontSize: 18,
-    letterSpacing: 0.2,
-    fontWeight: '400',
-    lineHeight: 23.4, // 1.3*16
-  },
   replyToLayout: {
     flexDirection: 'row',
     borderTopWidth: 1,
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 23dcaffd5..658023330 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -75,6 +75,7 @@ const styles = StyleSheet.create({
     borderWidth: 1,
     borderRadius: 8,
     marginTop: 20,
+    marginBottom: 10,
   },
   inner: {
     padding: 10,
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index 88d5de2bf..301b90093 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -4,12 +4,9 @@ import {UserAvatar} from '../util/UserAvatar'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
-export function ComposePrompt({
-  onPressCompose,
-}: {
-  onPressCompose: (imagesOpen?: boolean) => void
-}) {
+export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
   const store = useStores()
   const pal = usePalette('default')
   return (
@@ -17,13 +14,13 @@ export function ComposePrompt({
       testID="replyPromptBtn"
       style={[pal.view, pal.border, styles.prompt]}
       onPress={() => onPressCompose()}>
-      <UserAvatar
-        handle={store.me.handle}
-        avatar={store.me.avatar}
-        displayName={store.me.displayName}
-        size={38}
-      />
-      <Text type="xl" style={[pal.text, styles.label]}>
+      <UserAvatar avatar={store.me.avatar} size={38} />
+      <Text
+        type="xl"
+        style={[
+          pal.text,
+          isDesktopWeb ? styles.labelDesktopWeb : styles.labelMobile,
+        ]}>
         Write your reply
       </Text>
     </TouchableOpacity>
@@ -39,7 +36,10 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     borderTopWidth: 1,
   },
-  label: {
+  labelMobile: {
     paddingLeft: 12,
   },
+  labelDesktopWeb: {
+    paddingLeft: 20,
+  },
 })
diff --git a/src/view/com/composer/autocomplete/Autocomplete.tsx b/src/view/com/composer/autocomplete/Autocomplete.tsx
deleted file mode 100644
index 82fb239da..000000000
--- a/src/view/com/composer/autocomplete/Autocomplete.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import React, {useEffect} from 'react'
-import {
-  Animated,
-  TouchableOpacity,
-  StyleSheet,
-  useWindowDimensions,
-} from 'react-native'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from '../../util/text/Text'
-
-interface AutocompleteItem {
-  handle: string
-  displayName?: string
-}
-
-export function Autocomplete({
-  active,
-  items,
-  onSelect,
-}: {
-  active: boolean
-  items: AutocompleteItem[]
-  onSelect: (item: string) => void
-}) {
-  const pal = usePalette('default')
-  const winDim = useWindowDimensions()
-  const positionInterp = useAnimatedValue(0)
-
-  useEffect(() => {
-    Animated.timing(positionInterp, {
-      toValue: active ? 1 : 0,
-      duration: 200,
-      useNativeDriver: false,
-    }).start()
-  }, [positionInterp, active])
-
-  const topAnimStyle = {
-    top: positionInterp.interpolate({
-      inputRange: [0, 1],
-      outputRange: [winDim.height, winDim.height / 4],
-    }),
-  }
-  return (
-    <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}>
-      {items.map((item, i) => (
-        <TouchableOpacity
-          testID="autocompleteButton"
-          key={i}
-          style={[pal.border, styles.item]}
-          onPress={() => onSelect(item.handle)}>
-          <Text type="md-medium" style={pal.text}>
-            {item.displayName || item.handle}
-            <Text type="sm" style={pal.textLight}>
-              &nbsp;@{item.handle}
-            </Text>
-          </Text>
-        </TouchableOpacity>
-      ))}
-    </Animated.View>
-  )
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    position: 'absolute',
-    left: 0,
-    right: 0,
-    bottom: 0,
-    borderTopWidth: 1,
-  },
-  item: {
-    borderBottomWidth: 1,
-    paddingVertical: 16,
-    paddingHorizontal: 16,
-  },
-})
diff --git a/src/view/com/composer/autocomplete/Autocomplete.web.tsx b/src/view/com/composer/autocomplete/Autocomplete.web.tsx
deleted file mode 100644
index b6be1c21e..000000000
--- a/src/view/com/composer/autocomplete/Autocomplete.web.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react'
-import {TouchableOpacity, StyleSheet, View} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from '../../util/text/Text'
-
-interface AutocompleteItem {
-  handle: string
-  displayName?: string
-}
-
-export function Autocomplete({
-  active,
-  items,
-  onSelect,
-}: {
-  active: boolean
-  items: AutocompleteItem[]
-  onSelect: (item: string) => void
-}) {
-  const pal = usePalette('default')
-
-  if (!active) {
-    return <View />
-  }
-  return (
-    <View style={[styles.outer, pal.view, pal.border]}>
-      {items.map((item, i) => (
-        <TouchableOpacity
-          testID="autocompleteButton"
-          key={i}
-          style={[pal.border, styles.item]}
-          onPress={() => onSelect(item.handle)}>
-          <Text type="md-medium" style={pal.text}>
-            {item.displayName || item.handle}
-            <Text type="sm" style={pal.textLight}>
-              &nbsp;@{item.handle}
-            </Text>
-          </Text>
-        </TouchableOpacity>
-      ))}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    position: 'absolute',
-    left: 0,
-    right: 0,
-    top: '100%',
-    borderWidth: 1,
-    borderRadius: 8,
-  },
-  item: {
-    borderBottomWidth: 1,
-    paddingVertical: 16,
-    paddingHorizontal: 16,
-  },
-})
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
new file mode 100644
index 000000000..cf4a4c7d1
--- /dev/null
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -0,0 +1,84 @@
+import React from 'react'
+import {TouchableOpacity} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+import {openCamera} from 'lib/media/picker'
+import {compressIfNeeded} from 'lib/media/manip'
+import {useCameraPermission} from 'lib/hooks/usePermissions'
+import {
+  POST_IMG_MAX_WIDTH,
+  POST_IMG_MAX_HEIGHT,
+  POST_IMG_MAX_SIZE,
+} from 'lib/constants'
+
+const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
+
+export function OpenCameraBtn({
+  enabled,
+  selectedPhotos,
+  onSelectPhotos,
+}: {
+  enabled: boolean
+  selectedPhotos: string[]
+  onSelectPhotos: (v: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const store = useStores()
+  const {requestCameraAccessIfNeeded} = useCameraPermission()
+
+  const onPressTakePicture = React.useCallback(async () => {
+    track('Composer:CameraOpened')
+    if (!enabled) {
+      return
+    }
+    try {
+      if (!(await requestCameraAccessIfNeeded())) {
+        return
+      }
+      const cameraRes = await openCamera(store, {
+        mediaType: 'photo',
+        width: POST_IMG_MAX_WIDTH,
+        height: POST_IMG_MAX_HEIGHT,
+        freeStyleCropEnabled: true,
+      })
+      const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
+      onSelectPhotos([...selectedPhotos, img.path])
+    } catch (err: any) {
+      // ignore
+      store.log.warn('Error using camera', err)
+    }
+  }, [
+    track,
+    store,
+    onSelectPhotos,
+    selectedPhotos,
+    enabled,
+    requestCameraAccessIfNeeded,
+  ])
+
+  if (isDesktopWeb) {
+    return <></>
+  }
+
+  return (
+    <TouchableOpacity
+      testID="openCameraButton"
+      onPress={onPressTakePicture}
+      style={[s.pl5]}
+      hitSlop={HITSLOP}>
+      <FontAwesomeIcon
+        icon="camera"
+        style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
+        size={24}
+      />
+    </TouchableOpacity>
+  )
+}
diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx
deleted file mode 100644
index 580e9746e..000000000
--- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import React, {useCallback} from 'react'
-import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useAnalytics} from 'lib/analytics'
-import {
-  openPicker,
-  openCamera,
-  cropAndCompressFlow,
-} from '../../../../lib/media/picker'
-import {
-  UserLocalPhotosModel,
-  PhotoIdentifier,
-} from 'state/models/user-local-photos'
-import {compressIfNeeded} from 'lib/media/manip'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
-import {
-  requestPhotoAccessIfNeeded,
-  requestCameraAccessIfNeeded,
-} from 'lib/permissions'
-import {
-  POST_IMG_MAX_WIDTH,
-  POST_IMG_MAX_HEIGHT,
-  POST_IMG_MAX_SIZE,
-} from 'lib/constants'
-
-export const PhotoCarouselPicker = ({
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) => {
-  const {track} = useAnalytics()
-  const pal = usePalette('default')
-  const store = useStores()
-  const [isSetup, setIsSetup] = React.useState<boolean>(false)
-
-  const localPhotos = React.useMemo<UserLocalPhotosModel>(
-    () => new UserLocalPhotosModel(store),
-    [store],
-  )
-
-  React.useEffect(() => {
-    // initial setup
-    localPhotos.setup().then(() => {
-      setIsSetup(true)
-    })
-  }, [localPhotos])
-
-  const handleOpenCamera = useCallback(async () => {
-    try {
-      if (!(await requestCameraAccessIfNeeded())) {
-        return
-      }
-      const cameraRes = await openCamera(store, {
-        mediaType: 'photo',
-        width: POST_IMG_MAX_WIDTH,
-        height: POST_IMG_MAX_HEIGHT,
-        freeStyleCropEnabled: true,
-      })
-      const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
-      onSelectPhotos([...selectedPhotos, img.path])
-    } catch (err: any) {
-      // ignore
-      store.log.warn('Error using camera', err)
-    }
-  }, [store, selectedPhotos, onSelectPhotos])
-
-  const handleSelectPhoto = useCallback(
-    async (item: PhotoIdentifier) => {
-      track('PhotoCarouselPicker:PhotoSelected')
-      try {
-        const imgPath = await cropAndCompressFlow(
-          store,
-          item.node.image.uri,
-          {
-            width: item.node.image.width,
-            height: item.node.image.height,
-          },
-          {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-          POST_IMG_MAX_SIZE,
-        )
-        onSelectPhotos([...selectedPhotos, imgPath])
-      } catch (err: any) {
-        // ignore
-        store.log.warn('Error selecting photo', err)
-      }
-    },
-    [track, store, onSelectPhotos, selectedPhotos],
-  )
-
-  const handleOpenGallery = useCallback(async () => {
-    track('PhotoCarouselPicker:GalleryOpened')
-    if (!(await requestPhotoAccessIfNeeded())) {
-      return
-    }
-    const items = await openPicker(store, {
-      multiple: true,
-      maxFiles: 4 - selectedPhotos.length,
-      mediaType: 'photo',
-    })
-    const result = []
-    for (const image of items) {
-      result.push(
-        await cropAndCompressFlow(
-          store,
-          image.path,
-          image,
-          {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-          POST_IMG_MAX_SIZE,
-        ),
-      )
-    }
-    onSelectPhotos([...selectedPhotos, ...result])
-  }, [track, store, selectedPhotos, onSelectPhotos])
-
-  return (
-    <ScrollView
-      testID="photoCarouselPickerView"
-      horizontal
-      style={[pal.view, styles.photosContainer]}
-      keyboardShouldPersistTaps="always"
-      showsHorizontalScrollIndicator={false}>
-      <TouchableOpacity
-        testID="openCameraButton"
-        style={[styles.galleryButton, pal.border, styles.photo]}
-        onPress={handleOpenCamera}>
-        <FontAwesomeIcon
-          icon="camera"
-          size={24}
-          style={pal.link as FontAwesomeIconStyle}
-        />
-      </TouchableOpacity>
-      <TouchableOpacity
-        testID="openGalleryButton"
-        style={[styles.galleryButton, pal.border, styles.photo]}
-        onPress={handleOpenGallery}>
-        <FontAwesomeIcon
-          icon="image"
-          style={pal.link as FontAwesomeIconStyle}
-          size={24}
-        />
-      </TouchableOpacity>
-      {isSetup &&
-        localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
-          <TouchableOpacity
-            testID="openSelectPhotoButton"
-            key={`local-image-${index}`}
-            style={[pal.border, styles.photoButton]}
-            onPress={() => handleSelectPhoto(item)}>
-            <Image style={styles.photo} source={{uri: item.node.image.uri}} />
-          </TouchableOpacity>
-        ))}
-    </ScrollView>
-  )
-}
-
-const styles = StyleSheet.create({
-  photosContainer: {
-    width: '100%',
-    maxHeight: 96,
-    padding: 8,
-    overflow: 'hidden',
-  },
-  galleryButton: {
-    borderWidth: 1,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  photoButton: {
-    width: 75,
-    height: 75,
-    marginRight: 8,
-    borderWidth: 1,
-    borderRadius: 16,
-  },
-  photo: {
-    width: 75,
-    height: 75,
-    marginRight: 8,
-    borderRadius: 16,
-  },
-})
diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
deleted file mode 100644
index ff4350b0c..000000000
--- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from 'react'
-
-// Not used on Web
-
-export const PhotoCarouselPicker = (_opts: {
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) => {
-  return <></>
-}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
new file mode 100644
index 000000000..bdcb0534a
--- /dev/null
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import {TouchableOpacity} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
+import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
+import {
+  POST_IMG_MAX_WIDTH,
+  POST_IMG_MAX_HEIGHT,
+  POST_IMG_MAX_SIZE,
+} from 'lib/constants'
+
+const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
+
+export function SelectPhotoBtn({
+  enabled,
+  selectedPhotos,
+  onSelectPhotos,
+}: {
+  enabled: boolean
+  selectedPhotos: string[]
+  onSelectPhotos: (v: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const store = useStores()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+
+  const onPressSelectPhotos = React.useCallback(async () => {
+    track('Composer:GalleryOpened')
+    if (!enabled) {
+      return
+    }
+    if (isDesktopWeb) {
+      const images = await pickImagesFlow(
+        store,
+        4 - selectedPhotos.length,
+        {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
+        POST_IMG_MAX_SIZE,
+      )
+      onSelectPhotos([...selectedPhotos, ...images])
+    } else {
+      if (!(await requestPhotoAccessIfNeeded())) {
+        return
+      }
+      const items = await openPicker(store, {
+        multiple: true,
+        maxFiles: 4 - selectedPhotos.length,
+        mediaType: 'photo',
+      })
+      const result = []
+      for (const image of items) {
+        result.push(
+          await cropAndCompressFlow(
+            store,
+            image.path,
+            image,
+            {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
+            POST_IMG_MAX_SIZE,
+          ),
+        )
+      }
+      onSelectPhotos([...selectedPhotos, ...result])
+    }
+  }, [
+    track,
+    store,
+    onSelectPhotos,
+    selectedPhotos,
+    enabled,
+    requestPhotoAccessIfNeeded,
+  ])
+
+  return (
+    <TouchableOpacity
+      testID="openGalleryBtn"
+      onPress={onPressSelectPhotos}
+      style={[s.pl5, s.pr20]}
+      hitSlop={HITSLOP}>
+      <FontAwesomeIcon
+        icon={['far', 'image']}
+        style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
+        size={24}
+      />
+    </TouchableOpacity>
+  )
+}
diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx
index 6aeda33cd..c2a00ce53 100644
--- a/src/view/com/composer/SelectedPhoto.tsx
+++ b/src/view/com/composer/photos/SelectedPhotos.tsx
@@ -4,7 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import Image from 'view/com/util/images/Image'
 import {colors} from 'lib/styles'
 
-export const SelectedPhoto = ({
+export const SelectedPhotos = ({
   selectedPhotos,
   onSelectPhotos,
 }: {
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index be6150e11..2a40fb518 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,64 +1,222 @@
 import React from 'react'
 import {
   NativeSyntheticEvent,
-  StyleProp,
+  StyleSheet,
   TextInputSelectionChangeEventData,
-  TextStyle,
 } from 'react-native'
 import PasteInput, {
   PastedFile,
   PasteInputRef,
 } from '@mattermost/react-native-paste-input'
+import isEqual from 'lodash.isequal'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {Autocomplete} from './mobile/Autocomplete'
+import {Text} from 'view/com/util/text/Text'
+import {useStores} from 'state/index'
+import {cleanError} from 'lib/strings/errors'
+import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
+import {getImageDim} from 'lib/media/manip'
+import {cropAndCompressFlow} from 'lib/media/picker'
+import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
+import {
+  POST_IMG_MAX_WIDTH,
+  POST_IMG_MAX_HEIGHT,
+  POST_IMG_MAX_SIZE,
+} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 
-export type TextInputRef = PasteInputRef
+export interface TextInputRef {
+  focus: () => void
+  blur: () => void
+}
 
 interface TextInputProps {
-  testID: string
-  innerRef: React.Ref<TextInputRef>
+  text: string
   placeholder: string
-  style: StyleProp<TextStyle>
-  onChangeText: (str: string) => void
-  onSelectionChange?:
-    | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
-    | undefined
-  onPaste: (err: string | undefined, uris: string[]) => void
+  suggestedLinks: Set<string>
+  autocompleteView: UserAutocompleteViewModel
+  onTextChanged: (v: string) => void
+  onPhotoPasted: (uri: string) => void
+  onSuggestedLinksChanged: (uris: Set<string>) => void
+  onError: (err: string) => void
 }
 
-export function TextInput({
-  testID,
-  innerRef,
-  placeholder,
-  style,
-  onChangeText,
-  onSelectionChange,
-  onPaste,
-  children,
-}: React.PropsWithChildren<TextInputProps>) {
-  const pal = usePalette('default')
-  const onPasteInner = (err: string | undefined, files: PastedFile[]) => {
-    if (err) {
-      onPaste(err, [])
-    } else {
-      onPaste(
-        undefined,
-        files.map(f => f.uri),
-      )
-    }
-  }
-  return (
-    <PasteInput
-      testID={testID}
-      ref={innerRef}
-      multiline
-      scrollEnabled
-      onChangeText={(str: string) => onChangeText(str)}
-      onSelectionChange={onSelectionChange}
-      onPaste={onPasteInner}
-      placeholder={placeholder}
-      placeholderTextColor={pal.colors.textLight}
-      style={style}>
-      {children}
-    </PasteInput>
-  )
+interface Selection {
+  start: number
+  end: number
 }
+
+export const TextInput = React.forwardRef(
+  (
+    {
+      text,
+      placeholder,
+      suggestedLinks,
+      autocompleteView,
+      onTextChanged,
+      onPhotoPasted,
+      onSuggestedLinksChanged,
+      onError,
+    }: TextInputProps,
+    ref,
+  ) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const textInput = React.useRef<PasteInputRef>(null)
+    const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
+    const theme = useTheme()
+
+    React.useImperativeHandle(ref, () => ({
+      focus: () => textInput.current?.focus(),
+      blur: () => textInput.current?.blur(),
+    }))
+
+    React.useEffect(() => {
+      // HACK
+      // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
+      // -prf
+      let to: NodeJS.Timeout | undefined
+      if (textInput.current) {
+        to = setTimeout(() => {
+          textInput.current?.focus()
+        }, 250)
+      }
+      return () => {
+        if (to) {
+          clearTimeout(to)
+        }
+      }
+    }, [])
+
+    const onChangeText = React.useCallback(
+      (newText: string) => {
+        onTextChanged(newText)
+
+        const prefix = getMentionAt(
+          newText,
+          textInputSelection.current?.start || 0,
+        )
+        if (prefix) {
+          autocompleteView.setActive(true)
+          autocompleteView.setPrefix(prefix.value)
+        } else {
+          autocompleteView.setActive(false)
+        }
+
+        const ents = extractEntities(newText)?.filter(
+          ent => ent.type === 'link',
+        )
+        const set = new Set(ents ? ents.map(e => e.value) : [])
+        if (!isEqual(set, suggestedLinks)) {
+          onSuggestedLinksChanged(set)
+        }
+      },
+      [
+        onTextChanged,
+        autocompleteView,
+        suggestedLinks,
+        onSuggestedLinksChanged,
+      ],
+    )
+
+    const onPaste = React.useCallback(
+      async (err: string | undefined, files: PastedFile[]) => {
+        if (err) {
+          return onError(cleanError(err))
+        }
+        const uris = files.map(f => f.uri)
+        const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
+        if (imgUri) {
+          let imgDim
+          try {
+            imgDim = await getImageDim(imgUri)
+          } catch (e) {
+            imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
+          }
+          const finalImgPath = await cropAndCompressFlow(
+            store,
+            imgUri,
+            imgDim,
+            {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
+            POST_IMG_MAX_SIZE,
+          )
+          onPhotoPasted(finalImgPath)
+        }
+      },
+      [store, onError, onPhotoPasted],
+    )
+
+    const onSelectionChange = React.useCallback(
+      (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
+        // NOTE we track the input selection using a ref to avoid excessive renders -prf
+        textInputSelection.current = evt.nativeEvent.selection
+      },
+      [textInputSelection],
+    )
+
+    const onSelectAutocompleteItem = React.useCallback(
+      (item: string) => {
+        onChangeText(
+          insertMentionAt(text, textInputSelection.current?.start || 0, item),
+        )
+        autocompleteView.setActive(false)
+      },
+      [onChangeText, text, autocompleteView],
+    )
+
+    const textDecorated = React.useMemo(() => {
+      let i = 0
+      return detectLinkables(text).map(v => {
+        if (typeof v === 'string') {
+          return (
+            <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
+              {v}
+            </Text>
+          )
+        } else {
+          return (
+            <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
+              {v.link}
+            </Text>
+          )
+        }
+      })
+    }, [text, pal.link, pal.text])
+
+    return (
+      <>
+        <PasteInput
+          testID="composerTextInput"
+          ref={textInput}
+          onChangeText={onChangeText}
+          onPaste={onPaste}
+          onSelectionChange={onSelectionChange}
+          placeholder={placeholder}
+          keyboardAppearance={theme.colorScheme}
+          style={[pal.text, styles.textInput, styles.textInputFormatting]}>
+          {textDecorated}
+        </PasteInput>
+        <Autocomplete
+          view={autocompleteView}
+          onSelect={onSelectAutocompleteItem}
+        />
+      </>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  textInput: {
+    flex: 1,
+    padding: 5,
+    marginLeft: 8,
+    alignSelf: 'flex-start',
+  },
+  textInputFormatting: {
+    fontSize: 18,
+    letterSpacing: 0.2,
+    fontWeight: '400',
+    lineHeight: 23.4, // 1.3*16
+  },
+})
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 2b610850c..67ef836a0 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,58 +1,133 @@
 import React from 'react'
-import {
-  NativeSyntheticEvent,
-  StyleProp,
-  StyleSheet,
-  TextInput as RNTextInput,
-  TextInputSelectionChangeEventData,
-  TextStyle,
-} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {addStyle} from 'lib/styles'
-
-export type TextInputRef = RNTextInput
+import {StyleSheet, View} from 'react-native'
+import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
+import {Document} from '@tiptap/extension-document'
+import {Link} from '@tiptap/extension-link'
+import {Mention} from '@tiptap/extension-mention'
+import {Paragraph} from '@tiptap/extension-paragraph'
+import {Placeholder} from '@tiptap/extension-placeholder'
+import {Text} from '@tiptap/extension-text'
+import isEqual from 'lodash.isequal'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {createSuggestion} from './web/Autocomplete'
+
+export interface TextInputRef {
+  focus: () => void
+  blur: () => void
+}
 
 interface TextInputProps {
-  testID: string
-  innerRef: React.Ref<TextInputRef>
+  text: string
   placeholder: string
-  style: StyleProp<TextStyle>
-  onChangeText: (str: string) => void
-  onSelectionChange?:
-    | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
-    | undefined
-  onPaste: (err: string | undefined, uris: string[]) => void
+  suggestedLinks: Set<string>
+  autocompleteView: UserAutocompleteViewModel
+  onTextChanged: (v: string) => void
+  onPhotoPasted: (uri: string) => void
+  onSuggestedLinksChanged: (uris: Set<string>) => void
+  onError: (err: string) => void
 }
 
-export function TextInput({
-  testID,
-  innerRef,
-  placeholder,
-  style,
-  onChangeText,
-  onSelectionChange,
-  children,
-}: React.PropsWithChildren<TextInputProps>) {
-  const pal = usePalette('default')
-  style = addStyle(style, styles.input)
-  return (
-    <RNTextInput
-      testID={testID}
-      ref={innerRef}
-      multiline
-      scrollEnabled
-      onChangeText={(str: string) => onChangeText(str)}
-      onSelectionChange={onSelectionChange}
-      placeholder={placeholder}
-      placeholderTextColor={pal.colors.textLight}
-      style={style}>
-      {children}
-    </RNTextInput>
-  )
+export const TextInput = React.forwardRef(
+  (
+    {
+      text,
+      placeholder,
+      suggestedLinks,
+      autocompleteView,
+      onTextChanged,
+      // onPhotoPasted, TODO
+      onSuggestedLinksChanged,
+    }: // onError, TODO
+    TextInputProps,
+    ref,
+  ) => {
+    const editor = useEditor({
+      extensions: [
+        Document,
+        Link.configure({
+          protocols: ['http', 'https'],
+          autolink: true,
+        }),
+        Mention.configure({
+          HTMLAttributes: {
+            class: 'mention',
+          },
+          suggestion: createSuggestion({autocompleteView}),
+        }),
+        Paragraph,
+        Placeholder.configure({
+          placeholder,
+        }),
+        Text,
+      ],
+      content: text,
+      autofocus: true,
+      editable: true,
+      injectCSS: true,
+      onUpdate({editor: editorProp}) {
+        const json = editorProp.getJSON()
+        const newText = editorJsonToText(json).trim()
+        onTextChanged(newText)
+
+        const newSuggestedLinks = new Set(editorJsonToLinks(json))
+        if (!isEqual(newSuggestedLinks, suggestedLinks)) {
+          onSuggestedLinksChanged(newSuggestedLinks)
+        }
+      },
+    })
+
+    React.useImperativeHandle(ref, () => ({
+      focus: () => {}, // TODO
+      blur: () => {}, // TODO
+    }))
+
+    return (
+      <View style={styles.container}>
+        <EditorContent editor={editor} />
+      </View>
+    )
+  },
+)
+
+function editorJsonToText(json: JSONContent): string {
+  let text = ''
+  if (json.type === 'doc' || json.type === 'paragraph') {
+    if (json.content?.length) {
+      for (const node of json.content) {
+        text += editorJsonToText(node)
+      }
+    }
+    text += '\n'
+  } else if (json.type === 'text') {
+    text += json.text || ''
+  } else if (json.type === 'mention') {
+    text += json.attrs?.id || ''
+  }
+  return text
+}
+
+function editorJsonToLinks(json: JSONContent): string[] {
+  let links: string[] = []
+  if (json.content?.length) {
+    for (const node of json.content) {
+      links = links.concat(editorJsonToLinks(node))
+    }
+  }
+
+  const link = json.marks?.find(m => m.type === 'link')
+  if (link?.attrs?.href) {
+    links.push(link.attrs.href)
+  }
+
+  return links
 }
 
 const styles = StyleSheet.create({
-  input: {
-    minHeight: 140,
+  container: {
+    flex: 1,
+    alignSelf: 'flex-start',
+    padding: 5,
+    marginLeft: 8,
+    marginBottom: 10,
   },
 })
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
new file mode 100644
index 000000000..424a8629f
--- /dev/null
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -0,0 +1,75 @@
+import React, {useEffect} from 'react'
+import {
+  Animated,
+  TouchableOpacity,
+  StyleSheet,
+  useWindowDimensions,
+} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Text} from 'view/com/util/text/Text'
+
+export const Autocomplete = observer(
+  ({
+    view,
+    onSelect,
+  }: {
+    view: UserAutocompleteViewModel
+    onSelect: (item: string) => void
+  }) => {
+    const pal = usePalette('default')
+    const winDim = useWindowDimensions()
+    const positionInterp = useAnimatedValue(0)
+
+    useEffect(() => {
+      Animated.timing(positionInterp, {
+        toValue: view.isActive ? 1 : 0,
+        duration: 200,
+        useNativeDriver: false,
+      }).start()
+    }, [positionInterp, view.isActive])
+
+    const topAnimStyle = {
+      top: positionInterp.interpolate({
+        inputRange: [0, 1],
+        outputRange: [winDim.height, winDim.height / 4],
+      }),
+    }
+    return (
+      <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}>
+        {view.suggestions.map(item => (
+          <TouchableOpacity
+            testID="autocompleteButton"
+            key={item.handle}
+            style={[pal.border, styles.item]}
+            onPress={() => onSelect(item.handle)}>
+            <Text type="md-medium" style={pal.text}>
+              {item.displayName || item.handle}
+              <Text type="sm" style={pal.textLight}>
+                &nbsp;@{item.handle}
+              </Text>
+            </Text>
+          </TouchableOpacity>
+        ))}
+      </Animated.View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  outer: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    bottom: 0,
+    borderTopWidth: 1,
+  },
+  item: {
+    borderBottomWidth: 1,
+    paddingVertical: 16,
+    paddingHorizontal: 16,
+    height: 50,
+  },
+})
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
new file mode 100644
index 000000000..fbe438969
--- /dev/null
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -0,0 +1,157 @@
+import React, {
+  forwardRef,
+  useEffect,
+  useImperativeHandle,
+  useState,
+} from 'react'
+import {ReactRenderer} from '@tiptap/react'
+import tippy, {Instance as TippyInstance} from 'tippy.js'
+import {
+  SuggestionOptions,
+  SuggestionProps,
+  SuggestionKeyDownProps,
+} from '@tiptap/suggestion'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+
+interface MentionListRef {
+  onKeyDown: (props: SuggestionKeyDownProps) => boolean
+}
+
+export function createSuggestion({
+  autocompleteView,
+}: {
+  autocompleteView: UserAutocompleteViewModel
+}): Omit<SuggestionOptions, 'editor'> {
+  return {
+    async items({query}) {
+      autocompleteView.setActive(true)
+      await autocompleteView.setPrefix(query)
+      return autocompleteView.suggestions.slice(0, 8).map(s => s.handle)
+    },
+
+    render: () => {
+      let component: ReactRenderer<MentionListRef> | undefined
+      let popup: TippyInstance[] | undefined
+
+      return {
+        onStart: props => {
+          component = new ReactRenderer(MentionList, {
+            props,
+            editor: props.editor,
+          })
+
+          if (!props.clientRect) {
+            return
+          }
+
+          // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
+          popup = tippy('body', {
+            getReferenceClientRect: props.clientRect,
+            appendTo: () => document.body,
+            content: component.element,
+            showOnCreate: true,
+            interactive: true,
+            trigger: 'manual',
+            placement: 'bottom-start',
+          })
+        },
+
+        onUpdate(props) {
+          component?.updateProps(props)
+
+          if (!props.clientRect) {
+            return
+          }
+
+          popup?.[0]?.setProps({
+            // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
+            getReferenceClientRect: props.clientRect,
+          })
+        },
+
+        onKeyDown(props) {
+          if (props.event.key === 'Escape') {
+            popup?.[0]?.hide()
+
+            return true
+          }
+
+          return component?.ref?.onKeyDown(props) || false
+        },
+
+        onExit() {
+          popup?.[0]?.destroy()
+          component?.destroy()
+        },
+      }
+    },
+  }
+}
+
+const MentionList = forwardRef<MentionListRef, SuggestionProps>(
+  (props: SuggestionProps, ref) => {
+    const [selectedIndex, setSelectedIndex] = useState(0)
+
+    const selectItem = (index: number) => {
+      const item = props.items[index]
+
+      if (item) {
+        props.command({id: item})
+      }
+    }
+
+    const upHandler = () => {
+      setSelectedIndex(
+        (selectedIndex + props.items.length - 1) % props.items.length,
+      )
+    }
+
+    const downHandler = () => {
+      setSelectedIndex((selectedIndex + 1) % props.items.length)
+    }
+
+    const enterHandler = () => {
+      selectItem(selectedIndex)
+    }
+
+    useEffect(() => setSelectedIndex(0), [props.items])
+
+    useImperativeHandle(ref, () => ({
+      onKeyDown: ({event}) => {
+        if (event.key === 'ArrowUp') {
+          upHandler()
+          return true
+        }
+
+        if (event.key === 'ArrowDown') {
+          downHandler()
+          return true
+        }
+
+        if (event.key === 'Enter') {
+          enterHandler()
+          return true
+        }
+
+        return false
+      },
+    }))
+
+    return (
+      <div className="items">
+        {props.items.length ? (
+          props.items.map((item, index) => (
+            <button
+              className={`item ${index === selectedIndex ? 'is-selected' : ''}`}
+              key={index}
+              onClick={() => selectItem(index)}>
+              {item}
+            </button>
+          ))
+        ) : (
+          <div className="item">No result</div>
+        )}
+      </div>
+    )
+  },
+)
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
new file mode 100644
index 000000000..75f833e84
--- /dev/null
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -0,0 +1,90 @@
+import {useState, useEffect} from 'react'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
+import {getLinkMeta} from 'lib/link-meta/link-meta'
+import {getPostAsQuote} from 'lib/link-meta/bsky'
+import {downloadAndResize} from 'lib/media/manip'
+import {isBskyPostUrl} from 'lib/strings/url-helpers'
+import {ComposerOpts} from 'state/models/shell-ui'
+
+export function useExternalLinkFetch({
+  setQuote,
+}: {
+  setQuote: (opts: ComposerOpts['quote']) => void
+}) {
+  const store = useStores()
+  const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
+    undefined,
+  )
+
+  useEffect(() => {
+    let aborted = false
+    const cleanup = () => {
+      aborted = true
+    }
+    if (!extLink) {
+      return cleanup
+    }
+    if (!extLink.meta) {
+      if (isBskyPostUrl(extLink.uri)) {
+        getPostAsQuote(store, extLink.uri).then(
+          newQuote => {
+            if (aborted) {
+              return
+            }
+            setQuote(newQuote)
+            setExtLink(undefined)
+          },
+          err => {
+            store.log.error('Failed to fetch post for quote embedding', {err})
+            setExtLink(undefined)
+          },
+        )
+      } else {
+        getLinkMeta(store, extLink.uri).then(meta => {
+          if (aborted) {
+            return
+          }
+          setExtLink({
+            uri: extLink.uri,
+            isLoading: !!meta.image,
+            meta,
+          })
+        })
+      }
+      return cleanup
+    }
+    if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
+      console.log('attempting download')
+      downloadAndResize({
+        uri: extLink.meta.image,
+        width: 2000,
+        height: 2000,
+        mode: 'contain',
+        maxSize: 1000000,
+        timeout: 15e3,
+      })
+        .catch(() => undefined)
+        .then(localThumb => {
+          if (aborted) {
+            return
+          }
+          setExtLink({
+            ...extLink,
+            isLoading: false, // done
+            localThumb,
+          })
+        })
+      return cleanup
+    }
+    if (extLink.isLoading) {
+      setExtLink({
+        ...extLink,
+        isLoading: false, // done
+      })
+    }
+    return cleanup
+  }, [store, extLink, setQuote])
+
+  return {extLink, setExtLink}
+}
diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx
index 3c09a6cc2..a24dc4e35 100644
--- a/src/view/com/login/CreateAccount.tsx
+++ b/src/view/com/login/CreateAccount.tsx
@@ -27,11 +27,13 @@ import {toNiceDomain} from 'lib/strings/url-helpers'
 import {useStores, DEFAULT_SERVICE} from 'state/index'
 import {ServiceDescription} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {cleanError} from 'lib/strings/errors'
 
 export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
   const {track, screen, identify} = useAnalytics()
   const pal = usePalette('default')
+  const theme = useTheme()
   const store = useStores()
   const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
   const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE)
@@ -220,6 +222,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
                     autoCapitalize="none"
                     autoCorrect={false}
                     autoFocus
+                    keyboardAppearance={theme.colorScheme}
                     value={inviteCode}
                     onChangeText={setInviteCode}
                     onBlur={onBlurInviteCode}
diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx
index 4f994f831..6faf5ff12 100644
--- a/src/view/com/login/Signin.tsx
+++ b/src/view/com/login/Signin.tsx
@@ -26,6 +26,7 @@ import {ServiceDescription} from 'state/models/session'
 import {AccountData} from 'state/models/session'
 import {isNetworkError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {cleanError} from 'lib/strings/errors'
 
 enum Forms {
@@ -195,12 +196,7 @@ const ChooseAccountForm = ({
           <View
             style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
             <View style={s.p10}>
-              <UserAvatar
-                displayName={account.displayName}
-                handle={account.handle}
-                avatar={account.aviUrl}
-                size={30}
-              />
+              <UserAvatar avatar={account.aviUrl} size={30} />
             </View>
             <Text style={styles.accountText}>
               <Text type="lg-bold" style={pal.text}>
@@ -273,6 +269,7 @@ const LoginForm = ({
 }) => {
   const {track} = useAnalytics()
   const pal = usePalette('default')
+  const theme = useTheme()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [identifier, setIdentifier] = useState<string>(initialHandle)
   const [password, setPassword] = useState<string>('')
@@ -383,6 +380,7 @@ const LoginForm = ({
             autoCapitalize="none"
             autoFocus
             autoCorrect={false}
+            keyboardAppearance={theme.colorScheme}
             value={identifier}
             onChangeText={str => setIdentifier((str || '').toLowerCase())}
             editable={!isProcessing}
@@ -400,6 +398,7 @@ const LoginForm = ({
             placeholderTextColor={pal.colors.textLight}
             autoCapitalize="none"
             autoCorrect={false}
+            keyboardAppearance={theme.colorScheme}
             secureTextEntry
             value={password}
             onChangeText={setPassword}
@@ -479,6 +478,7 @@ const ForgotPasswordForm = ({
   onEmailSent: () => void
 }) => {
   const pal = usePalette('default')
+  const theme = useTheme()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [email, setEmail] = useState<string>('')
   const {screen} = useAnalytics()
@@ -567,6 +567,7 @@ const ForgotPasswordForm = ({
               autoCapitalize="none"
               autoFocus
               autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
               value={email}
               onChangeText={setEmail}
               editable={!isProcessing}
@@ -630,11 +631,12 @@ const SetNewPasswordForm = ({
   onPasswordSet: () => void
 }) => {
   const pal = usePalette('default')
+  const theme = useTheme()
   const {screen} = useAnalytics()
 
-  // useEffect(() => {
-  screen('Signin:SetNewPasswordForm')
-  // }, [screen])
+  useEffect(() => {
+    screen('Signin:SetNewPasswordForm')
+  }, [screen])
 
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [resetCode, setResetCode] = useState<string>('')
@@ -692,6 +694,7 @@ const SetNewPasswordForm = ({
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
               autoFocus
               value={resetCode}
               onChangeText={setResetCode}
@@ -710,6 +713,7 @@ const SetNewPasswordForm = ({
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
               secureTextEntry
               value={password}
               onChangeText={setPassword}
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index 519be7b2e..0795d6d20 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -17,6 +17,7 @@ import {ServiceDescription} from 'state/models/session'
 import {s} from 'lib/styles'
 import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics'
 import {cleanError} from 'lib/strings/errors'
 
@@ -212,6 +213,7 @@ function ProvidedHandleForm({
   setCanSave: (v: boolean) => void
 }) {
   const pal = usePalette('default')
+  const theme = useTheme()
 
   // events
   // =
@@ -239,6 +241,7 @@ function ProvidedHandleForm({
           placeholder="eg alice"
           placeholderTextColor={pal.colors.textLight}
           autoCapitalize="none"
+          keyboardAppearance={theme.colorScheme}
           value={handle}
           onChangeText={onChangeHandle}
           editable={!isProcessing}
@@ -283,6 +286,7 @@ function CustomHandleForm({
   const pal = usePalette('default')
   const palSecondary = usePalette('secondary')
   const palError = usePalette('error')
+  const theme = useTheme()
   const [isVerifying, setIsVerifying] = React.useState(false)
   const [error, setError] = React.useState<string>('')
 
@@ -348,6 +352,7 @@ function CustomHandleForm({
           placeholder="eg alice.com"
           placeholderTextColor={pal.colors.textLight}
           autoCapitalize="none"
+          keyboardAppearance={theme.colorScheme}
           value={handle}
           onChangeText={onChangeHandle}
           editable={!isProcessing}
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index de29e728d..62fa9f386 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -12,13 +12,16 @@ import {Text} from '../util/text/Text'
 import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
+import {resetToTab} from '../../../Navigation'
 
 export const snapPoints = ['60%']
 
 export function Component({}: {}) {
   const pal = usePalette('default')
+  const theme = useTheme()
   const store = useStores()
   const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
   const [confirmCode, setConfirmCode] = React.useState<string>('')
@@ -46,7 +49,7 @@ export function Component({}: {}) {
         token: confirmCode,
       })
       Toast.show('Your account has been deleted')
-      store.nav.tab.fixedTabReset()
+      resetToTab('HomeTab')
       store.session.clear()
       store.shell.closeModal()
     } catch (e: any) {
@@ -117,6 +120,7 @@ export function Component({}: {}) {
               style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
               placeholder="Confirmation code"
               placeholderTextColor={pal.textLight.color}
+              keyboardAppearance={theme.colorScheme}
               value={confirmCode}
               onChangeText={setConfirmCode}
             />
@@ -127,6 +131,7 @@ export function Component({}: {}) {
               style={[styles.textInput, pal.borderDark, pal.text]}
               placeholder="Password"
               placeholderTextColor={pal.textLight.color}
+              keyboardAppearance={theme.colorScheme}
               secureTextEntry
               value={password}
               onChangeText={setPassword}
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 121831ada..6eb21d17d 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -20,6 +20,7 @@ import {compressIfNeeded} from 'lib/media/manip'
 import {UserBanner} from '../util/UserBanner'
 import {UserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics'
 import {cleanError, isNetworkError} from 'lib/strings/errors'
 
@@ -35,6 +36,7 @@ export function Component({
   const store = useStores()
   const [error, setError] = useState<string>('')
   const pal = usePalette('default')
+  const theme = useTheme()
   const {track} = useAnalytics()
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
@@ -133,9 +135,7 @@ export function Component({
             <UserAvatar
               size={80}
               avatar={userAvatar}
-              handle={profileView.handle}
               onSelectNewAvatar={onSelectNewAvatar}
-              displayName={profileView.displayName}
             />
           </View>
         </View>
@@ -160,6 +160,7 @@ export function Component({
             style={[styles.textArea, pal.text]}
             placeholder="e.g. Artist, dog-lover, and memelord."
             placeholderTextColor={colors.gray4}
+            keyboardAppearance={theme.colorScheme}
             multiline
             value={description}
             onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx
index 5a9a4cfed..1d352cec9 100644
--- a/src/view/com/modals/ServerInput.tsx
+++ b/src/view/com/modals/ServerInput.tsx
@@ -8,12 +8,14 @@ import {ScrollView, TextInput} from './util'
 import {Text} from '../util/text/Text'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
 import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
 
 export const snapPoints = ['80%']
 
 export function Component({onSelect}: {onSelect: (url: string) => void}) {
+  const theme = useTheme()
   const store = useStores()
   const [customUrl, setCustomUrl] = useState<string>('')
 
@@ -74,6 +76,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
               autoCapitalize="none"
               autoComplete="off"
               autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
               value={customUrl}
               onChangeText={setCustomUrl}
             />
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index b21681c7f..306686557 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -5,6 +5,7 @@ import {Slider} from '@miblanchard/react-native-slider'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from 'view/com/util/text/Text'
 import {PickedMedia} from 'lib/media/types'
+import {getDataUriSize} from 'lib/media/util'
 import {s, gradients} from 'lib/styles'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -54,7 +55,7 @@ export function Component({
         mediaType: 'photo',
         path: dataUri,
         mime: 'image/jpeg',
-        size: Math.round((dataUri.length * 3) / 4), // very rough estimate
+        size: getDataUriSize(dataUri),
         width: DIMS[as].width,
         height: DIMS[as].height,
       })
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index acd00a67d..1c2299b03 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -24,7 +24,7 @@ import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import {ImageHorzList} from '../util/images/ImageHorzList'
 import {Post} from '../post/Post'
-import {Link} from '../util/Link'
+import {Link, TextLink} from '../util/Link'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
@@ -186,15 +186,12 @@ export const FeedItem = observer(function FeedItem({
                 authors={authors}
               />
               <View style={styles.meta}>
-                <Link
+                <TextLink
                   key={authors[0].href}
-                  style={styles.metaItem}
+                  style={[pal.text, s.bold, styles.metaItem]}
                   href={authors[0].href}
-                  title={`@${authors[0].handle}`}>
-                  <Text style={[pal.text, s.bold]} lineHeight={1.2}>
-                    {authors[0].displayName || authors[0].handle}
-                  </Text>
-                </Link>
+                  text={authors[0].displayName || authors[0].handle}
+                />
                 {authors.length > 1 ? (
                   <>
                     <Text style={[styles.metaItem, pal.text]}>and</Text>
@@ -256,13 +253,9 @@ function CondensedAuthorsList({
         <Link
           style={s.mr5}
           href={authors[0].href}
-          title={`@${authors[0].handle}`}>
-          <UserAvatar
-            size={35}
-            displayName={authors[0].displayName}
-            handle={authors[0].handle}
-            avatar={authors[0].avatar}
-          />
+          title={`@${authors[0].handle}`}
+          asAnchor>
+          <UserAvatar size={35} avatar={authors[0].avatar} />
         </Link>
       </View>
     )
@@ -271,12 +264,7 @@ function CondensedAuthorsList({
     <View style={styles.avis}>
       {authors.slice(0, MAX_AUTHORS).map(author => (
         <View key={author.href} style={s.mr5}>
-          <UserAvatar
-            size={35}
-            displayName={author.displayName}
-            handle={author.handle}
-            avatar={author.avatar}
-          />
+          <UserAvatar size={35} avatar={author.avatar} />
         </View>
       ))}
       {authors.length > MAX_AUTHORS ? (
@@ -326,14 +314,10 @@ function ExpandedAuthorsList({
           key={author.href}
           href={author.href}
           title={author.displayName || author.handle}
-          style={styles.expandedAuthor}>
+          style={styles.expandedAuthor}
+          asAnchor>
           <View style={styles.expandedAuthorAvi}>
-            <UserAvatar
-              size={35}
-              displayName={author.displayName}
-              handle={author.handle}
-              avatar={author.avatar}
-            />
+            <UserAvatar size={35} avatar={author.avatar} />
           </View>
           <View style={s.flex1}>
             <Text
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 646d4b276..f84593db8 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,28 +1,43 @@
 import React, {useRef} from 'react'
 import {observer} from 'mobx-react-lite'
-import {ActivityIndicator} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
 import {
   PostThreadViewModel,
   PostThreadViewPostModel,
 } from 'state/models/post-thread-view'
 import {PostThreadItem} from './PostThreadItem'
+import {ComposePrompt} from '../composer/Prompt'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+import {usePalette} from 'lib/hooks/usePalette'
+
+const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
+const BOTTOM_BORDER = {
+  _reactKey: '__bottom_border__',
+  _isHighlightedPost: false,
+}
+type YieldedItem = PostThreadViewPostModel | typeof REPLY_PROMPT
 
 export const PostThread = observer(function PostThread({
   uri,
   view,
+  onPressReply,
 }: {
   uri: string
   view: PostThreadViewModel
+  onPressReply: () => void
 }) {
+  const pal = usePalette('default')
   const ref = useRef<FlatList>(null)
   const [isRefreshing, setIsRefreshing] = React.useState(false)
-  const posts = React.useMemo(
-    () => (view.thread ? Array.from(flattenThread(view.thread)) : []),
-    [view.thread],
-  )
+  const posts = React.useMemo(() => {
+    if (view.thread) {
+      return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER])
+    }
+    return []
+  }, [view.thread])
 
   // events
   // =
@@ -58,6 +73,23 @@ export const PostThread = observer(function PostThread({
     },
     [ref],
   )
+  const renderItem = React.useCallback(
+    ({item}: {item: YieldedItem}) => {
+      if (item === REPLY_PROMPT) {
+        return <ComposePrompt onPressCompose={onPressReply} />
+      } else if (item === BOTTOM_BORDER) {
+        // HACK
+        // due to some complexities with how flatlist works, this is the easiest way
+        // I could find to get a border positioned directly under the last item
+        // -prf
+        return <View style={[styles.bottomBorder, pal.border]} />
+      } else if (item instanceof PostThreadViewPostModel) {
+        return <PostThreadItem item={item} onPostReply={onRefresh} />
+      }
+      return <></>
+    },
+    [onRefresh, onPressReply, pal],
+  )
 
   // loading
   // =
@@ -81,9 +113,6 @@ export const PostThread = observer(function PostThread({
 
   // loaded
   // =
-  const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
-    <PostThreadItem item={item} onPostReply={onRefresh} />
-  )
   return (
     <FlatList
       ref={ref}
@@ -104,7 +133,7 @@ export const PostThread = observer(function PostThread({
 function* flattenThread(
   post: PostThreadViewPostModel,
   isAscending = false,
-): Generator<PostThreadViewPostModel, void> {
+): Generator<YieldedItem, void> {
   if (post.parent) {
     if ('notFound' in post.parent && post.parent.notFound) {
       // TODO render not found
@@ -113,6 +142,9 @@ function* flattenThread(
     }
   }
   yield post
+  if (isDesktopWeb && post._isHighlightedPost) {
+    yield REPLY_PROMPT
+  }
   if (post.replies?.length) {
     for (const reply of post.replies) {
       if ('notFound' in reply && reply.notFound) {
@@ -125,3 +157,9 @@ function* flattenThread(
     post._hasMore = true
   }
 }
+
+const styles = StyleSheet.create({
+  bottomBorder: {
+    borderBottomWidth: 1,
+  },
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 1413148a9..17c7943d9 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -135,13 +135,8 @@ export const PostThreadItem = observer(function PostThreadItem({
           ]}>
           <View style={styles.layout}>
             <View style={styles.layoutAvi}>
-              <Link href={authorHref} title={authorTitle}>
-                <UserAvatar
-                  size={52}
-                  displayName={item.post.author.displayName}
-                  handle={item.post.author.handle}
-                  avatar={item.post.author.avatar}
-                />
+              <Link href={authorHref} title={authorTitle} asAnchor>
+                <UserAvatar size={52} avatar={item.post.author.avatar} />
               </Link>
             </View>
             <View style={styles.layoutContent}>
@@ -299,13 +294,8 @@ export const PostThreadItem = observer(function PostThreadItem({
           )}
           <View style={styles.layout}>
             <View style={styles.layoutAvi}>
-              <Link href={authorHref} title={authorTitle}>
-                <UserAvatar
-                  size={52}
-                  displayName={item.post.author.displayName}
-                  handle={item.post.author.handle}
-                  avatar={item.post.author.avatar}
-                />
+              <Link href={authorHref} title={authorTitle} asAnchor>
+                <UserAvatar size={52} avatar={item.post.author.avatar} />
               </Link>
             </View>
             <View style={styles.layoutContent}>
@@ -313,6 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 authorHandle={item.post.author.handle}
                 authorDisplayName={item.post.author.displayName}
                 timestamp={item.post.indexedAt}
+                postHref={itemHref}
                 did={item.post.author.did}
                 declarationCid={item.post.author.declaration.cid}
               />
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 7b4161afc..ac7d1cc55 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -150,13 +150,8 @@ export const Post = observer(function Post({
         {showReplyLine && <View style={styles.replyLine} />}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
-            <Link href={authorHref} title={authorTitle}>
-              <UserAvatar
-                size={52}
-                displayName={item.post.author.displayName}
-                handle={item.post.author.handle}
-                avatar={item.post.author.avatar}
-              />
+            <Link href={authorHref} title={authorTitle} asAnchor>
+              <UserAvatar size={52} avatar={item.post.author.avatar} />
             </Link>
           </View>
           <View style={styles.layoutContent}>
@@ -164,6 +159,7 @@ export const Post = observer(function Post({
               authorHandle={item.post.author.handle}
               authorDisplayName={item.post.author.displayName}
               timestamp={item.post.indexedAt}
+              postHref={itemHref}
               did={item.post.author.did}
               declarationCid={item.post.author.declaration.cid}
             />
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 5751faa68..8f57900b5 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -7,6 +7,7 @@ import {
   StyleSheet,
   ViewStyle,
 } from 'react-native'
+import {useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {CenteredView, FlatList} from '../util/Views'
@@ -18,10 +19,10 @@ import {FeedModel} from 'state/models/feed-view'
 import {FeedItem} from './FeedItem'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {useAnalytics} from 'lib/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {MagnifyingGlassIcon} from 'lib/icons'
+import {NavigationProp} from 'lib/routes/types'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_FEED_ITEM = {_reactKey: '__error__'}
@@ -47,9 +48,9 @@ export const Feed = observer(function Feed({
 }) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
-  const store = useStores()
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const navigation = useNavigation<NavigationProp>()
 
   const data = React.useMemo(() => {
     let feedItems: any[] = []
@@ -112,7 +113,12 @@ export const Feed = observer(function Feed({
             <Button
               type="inverted"
               style={styles.emptyBtn}
-              onPress={() => store.nav.navigate('/search')}>
+              onPress={
+                () =>
+                  navigation.navigate(
+                    'SearchTab',
+                  ) /* TODO make sure it goes to root of the tab */
+              }>
               <Text type="lg-medium" style={palInverted.text}>
                 Find accounts
               </Text>
@@ -134,7 +140,7 @@ export const Feed = observer(function Feed({
       }
       return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
     },
-    [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav],
+    [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
   )
 
   const FeedFooter = React.useCallback(
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 8b9a6eb2c..ec8feb664 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -9,7 +9,7 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {FeedItemModel} from 'state/models/feed-view'
-import {Link} from '../util/Link'
+import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
@@ -169,19 +169,24 @@ export const FeedItem = observer(function ({
               lineHeight={1.2}
               numberOfLines={1}>
               Reposted by{' '}
-              {item.reasonRepost.by.displayName || item.reasonRepost.by.handle}
+              <DesktopWebTextLink
+                type="sm-bold"
+                style={pal.textLight}
+                lineHeight={1.2}
+                numberOfLines={1}
+                text={
+                  item.reasonRepost.by.displayName ||
+                  item.reasonRepost.by.handle
+                }
+                href={`/profile/${item.reasonRepost.by.handle}`}
+              />
             </Text>
           </Link>
         )}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
-            <Link href={authorHref} title={item.post.author.handle}>
-              <UserAvatar
-                size={52}
-                displayName={item.post.author.displayName}
-                handle={item.post.author.handle}
-                avatar={item.post.author.avatar}
-              />
+            <Link href={authorHref} title={item.post.author.handle} asAnchor>
+              <UserAvatar size={52} avatar={item.post.author.avatar} />
             </Link>
           </View>
           <View style={styles.layoutContent}>
@@ -189,6 +194,7 @@ export const FeedItem = observer(function ({
               authorHandle={item.post.author.handle}
               authorDisplayName={item.post.author.displayName}
               timestamp={item.post.indexedAt}
+              postHref={itemHref}
               did={item.post.author.did}
               declarationCid={item.post.author.declaration.cid}
               showFollowBtn={showFollowBtn}
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 3c487b70f..087536c36 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -37,15 +37,11 @@ export function ProfileCard({
       ]}
       href={`/profile/${handle}`}
       title={handle}
-      noFeedback>
+      noFeedback
+      asAnchor>
       <View style={styles.layout}>
         <View style={styles.layoutAvi}>
-          <UserAvatar
-            size={40}
-            displayName={displayName}
-            handle={handle}
-            avatar={avatar}
-          />
+          <UserAvatar size={40} avatar={avatar} />
         </View>
         <View style={styles.layoutContent}>
           <Text
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 519d224ea..b061aac41 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -7,18 +7,18 @@ import {
   TouchableWithoutFeedback,
   View,
 } from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
 import {BlurView} from '../util/BlurView'
 import {ProfileViewModel} from 'state/models/profile-view'
 import {useStores} from 'state/index'
 import {ProfileImageLightbox} from 'state/models/shell-ui'
 import {pluralize} from 'lib/strings/helpers'
 import {toShareUrl} from 'lib/strings/url-helpers'
-import {s, gradients} from 'lib/styles'
+import {s, colors} from 'lib/styles'
 import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -28,6 +28,8 @@ import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics'
+import {NavigationProp} from 'lib/routes/types'
+import {isDesktopWeb} from 'platform/detection'
 
 const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30}
 
@@ -40,16 +42,17 @@ export const ProfileHeader = observer(function ProfileHeader({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const onPressBack = () => {
-    store.nav.tab.goBack()
-  }
-  const onPressAvi = () => {
+  const onPressBack = React.useCallback(() => {
+    navigation.goBack()
+  }, [navigation])
+  const onPressAvi = React.useCallback(() => {
     if (view.avatar) {
       store.shell.openLightbox(new ProfileImageLightbox(view))
     }
-  }
-  const onPressToggleFollow = () => {
+  }, [store, view])
+  const onPressToggleFollow = React.useCallback(() => {
     view?.toggleFollowing().then(
       () => {
         Toast.show(
@@ -60,28 +63,28 @@ export const ProfileHeader = observer(function ProfileHeader({
       },
       err => store.log.error('Failed to toggle follow', err),
     )
-  }
-  const onPressEditProfile = () => {
+  }, [view, store])
+  const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
     store.shell.openModal({
       name: 'edit-profile',
       profileView: view,
       onUpdate: onRefreshAll,
     })
-  }
-  const onPressFollowers = () => {
+  }, [track, store, view, onRefreshAll])
+  const onPressFollowers = React.useCallback(() => {
     track('ProfileHeader:FollowersButtonClicked')
-    store.nav.navigate(`/profile/${view.handle}/followers`)
-  }
-  const onPressFollows = () => {
+    navigation.push('ProfileFollowers', {name: view.handle})
+  }, [track, navigation, view])
+  const onPressFollows = React.useCallback(() => {
     track('ProfileHeader:FollowsButtonClicked')
-    store.nav.navigate(`/profile/${view.handle}/follows`)
-  }
-  const onPressShare = () => {
+    navigation.push('ProfileFollows', {name: view.handle})
+  }, [track, navigation, view])
+  const onPressShare = React.useCallback(() => {
     track('ProfileHeader:ShareButtonClicked')
     Share.share({url: toShareUrl(`/profile/${view.handle}`)})
-  }
-  const onPressMuteAccount = async () => {
+  }, [track, view])
+  const onPressMuteAccount = React.useCallback(async () => {
     track('ProfileHeader:MuteAccountButtonClicked')
     try {
       await view.muteAccount()
@@ -90,8 +93,8 @@ export const ProfileHeader = observer(function ProfileHeader({
       store.log.error('Failed to mute account', e)
       Toast.show(`There was an issue! ${e.toString()}`)
     }
-  }
-  const onPressUnmuteAccount = async () => {
+  }, [track, view, store])
+  const onPressUnmuteAccount = React.useCallback(async () => {
     track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
       await view.unmuteAccount()
@@ -100,14 +103,14 @@ export const ProfileHeader = observer(function ProfileHeader({
       store.log.error('Failed to unmute account', e)
       Toast.show(`There was an issue! ${e.toString()}`)
     }
-  }
-  const onPressReportAccount = () => {
+  }, [track, view, store])
+  const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
     store.shell.openModal({
       name: 'report-account',
       did: view.did,
     })
-  }
+  }, [track, store, view])
 
   // loading
   // =
@@ -189,23 +192,15 @@ export const ProfileHeader = observer(function ProfileHeader({
               ) : (
                 <TouchableOpacity
                   testID="profileHeaderToggleFollowButton"
-                  onPress={onPressToggleFollow}>
-                  <LinearGradient
-                    colors={[
-                      gradients.blueLight.start,
-                      gradients.blueLight.end,
-                    ]}
-                    start={{x: 0, y: 0}}
-                    end={{x: 1, y: 1}}
-                    style={[styles.btn, styles.gradientBtn]}>
-                    <FontAwesomeIcon
-                      icon="plus"
-                      style={[s.white as FontAwesomeIconStyle, s.mr5]}
-                    />
-                    <Text type="button" style={[s.white, s.bold]}>
-                      Follow
-                    </Text>
-                  </LinearGradient>
+                  onPress={onPressToggleFollow}
+                  style={[styles.btn, styles.primaryBtn]}>
+                  <FontAwesomeIcon
+                    icon="plus"
+                    style={[s.white as FontAwesomeIconStyle, s.mr5]}
+                  />
+                  <Text type="button" style={[s.white, s.bold]}>
+                    Follow
+                  </Text>
                 </TouchableOpacity>
               )}
             </>
@@ -287,24 +282,21 @@ export const ProfileHeader = observer(function ProfileHeader({
           </View>
         ) : undefined}
       </View>
-      <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}>
-        <View style={styles.backBtnWrapper}>
-          <BlurView style={styles.backBtn} blurType="dark">
-            <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
-          </BlurView>
-        </View>
-      </TouchableWithoutFeedback>
+      {!isDesktopWeb && (
+        <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}>
+          <View style={styles.backBtnWrapper}>
+            <BlurView style={styles.backBtn} blurType="dark">
+              <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
+            </BlurView>
+          </View>
+        </TouchableWithoutFeedback>
+      )}
       <TouchableWithoutFeedback
         testID="profileHeaderAviButton"
         onPress={onPressAvi}>
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-          <UserAvatar
-            size={80}
-            handle={view.handle}
-            displayName={view.displayName}
-            avatar={view.avatar}
-          />
+          <UserAvatar size={80} avatar={view.avatar} />
         </View>
       </TouchableWithoutFeedback>
     </View>
@@ -350,7 +342,8 @@ const styles = StyleSheet.create({
     marginLeft: 'auto',
     marginBottom: 12,
   },
-  gradientBtn: {
+  primaryBtn: {
+    backgroundColor: colors.blue3,
     paddingHorizontal: 24,
     paddingVertical: 6,
   },
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 017265f48..c7374e195 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
 import React, {Component, ErrorInfo, ReactNode} from 'react'
 import {ErrorScreen} from './error/ErrorScreen'
+import {CenteredView} from './Views'
 
 interface Props {
   children?: ReactNode
@@ -27,11 +28,13 @@ export class ErrorBoundary extends Component<Props, State> {
   public render() {
     if (this.state.hasError) {
       return (
-        <ErrorScreen
-          title="Oh no!"
-          message="There was an unexpected issue in the application. Please let us know if this happened to you!"
-          details={this.state.error.toString()}
-        />
+        <CenteredView>
+          <ErrorScreen
+            title="Oh no!"
+            message="There was an unexpected issue in the application. Please let us know if this happened to you!"
+            details={this.state.error.toString()}
+          />
+        </CenteredView>
       )
     }
 
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index bdc447937..cee4d4136 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -2,6 +2,8 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   Linking,
+  GestureResponderEvent,
+  Platform,
   StyleProp,
   TouchableWithoutFeedback,
   TouchableOpacity,
@@ -9,10 +11,22 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {
+  useLinkProps,
+  useNavigation,
+  StackActions,
+} from '@react-navigation/native'
 import {Text} from './text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '../../../routes'
 import {useStores, RootStoreModel} from 'state/index'
 import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
+import {isDesktopWeb} from 'platform/detection'
+
+type Event =
+  | React.MouseEvent<HTMLAnchorElement, MouseEvent>
+  | GestureResponderEvent
 
 export const Link = observer(function Link({
   style,
@@ -20,30 +34,33 @@ export const Link = observer(function Link({
   title,
   children,
   noFeedback,
+  asAnchor,
 }: {
   style?: StyleProp<ViewStyle>
   href?: string
   title?: string
   children?: React.ReactNode
   noFeedback?: boolean
+  asAnchor?: boolean
 }) {
   const store = useStores()
-  const onPress = () => {
-    if (href) {
-      handleLink(store, href, false)
-    }
-  }
-  const onLongPress = () => {
-    if (href) {
-      handleLink(store, href, true)
-    }
-  }
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPress = React.useCallback(
+    (e?: Event) => {
+      if (typeof href === 'string') {
+        return onPressInner(store, navigation, href, e)
+      }
+    },
+    [store, navigation, href],
+  )
+
   if (noFeedback) {
     return (
       <TouchableWithoutFeedback
         onPress={onPress}
-        onLongPress={onLongPress}
-        delayPressIn={50}>
+        // @ts-ignore web only -prf
+        href={asAnchor ? href : undefined}>
         <View style={style}>
           {children ? children : <Text>{title || 'link'}</Text>}
         </View>
@@ -52,10 +69,10 @@ export const Link = observer(function Link({
   }
   return (
     <TouchableOpacity
+      style={style}
       onPress={onPress}
-      onLongPress={onLongPress}
-      delayPressIn={50}
-      style={style}>
+      // @ts-ignore web only -prf
+      href={asAnchor ? href : undefined}>
       {children ? children : <Text>{title || 'link'}</Text>}
     </TouchableOpacity>
   )
@@ -66,35 +83,123 @@ export const TextLink = observer(function TextLink({
   style,
   href,
   text,
+  numberOfLines,
+  lineHeight,
 }: {
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
-  text: string
+  text: string | JSX.Element
+  numberOfLines?: number
+  lineHeight?: number
 }) {
+  const {...props} = useLinkProps({to: href})
   const store = useStores()
-  const onPress = () => {
-    handleLink(store, href, false)
-  }
-  const onLongPress = () => {
-    handleLink(store, href, true)
+  const navigation = useNavigation<NavigationProp>()
+
+  props.onPress = React.useCallback(
+    (e?: Event) => {
+      return onPressInner(store, navigation, href, e)
+    },
+    [store, navigation, href],
+  )
+
+  return (
+    <Text
+      type={type}
+      style={style}
+      numberOfLines={numberOfLines}
+      lineHeight={lineHeight}
+      {...props}>
+      {text}
+    </Text>
+  )
+})
+
+/**
+ * Only acts as a link on desktop web
+ */
+export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+  type = 'md',
+  style,
+  href,
+  text,
+  numberOfLines,
+  lineHeight,
+}: {
+  type?: TypographyVariant
+  style?: StyleProp<TextStyle>
+  href: string
+  text: string | JSX.Element
+  numberOfLines?: number
+  lineHeight?: number
+}) {
+  if (isDesktopWeb) {
+    return (
+      <TextLink
+        type={type}
+        style={style}
+        href={href}
+        text={text}
+        numberOfLines={numberOfLines}
+        lineHeight={lineHeight}
+      />
+    )
   }
   return (
-    <Text type={type} style={style} onPress={onPress} onLongPress={onLongPress}>
+    <Text
+      type={type}
+      style={style}
+      numberOfLines={numberOfLines}
+      lineHeight={lineHeight}>
       {text}
     </Text>
   )
 })
 
-function handleLink(store: RootStoreModel, href: string, longPress: boolean) {
-  href = convertBskyAppUrlIfNeeded(href)
-  if (href.startsWith('http')) {
-    Linking.openURL(href)
-  } else if (longPress) {
-    store.shell.closeModal() // close any active modals
-    store.nav.newTab(href)
-  } else {
-    store.shell.closeModal() // close any active modals
-    store.nav.navigate(href)
+// NOTE
+// we can't use the onPress given by useLinkProps because it will
+// match most paths to the HomeTab routes while we actually want to
+// preserve the tab the app is currently in
+//
+// we also have some additional behaviors - closing the current modal,
+// converting bsky urls, and opening http/s links in the system browser
+//
+// this method copies from the onPress implementation but adds our
+// needed customizations
+// -prf
+function onPressInner(
+  store: RootStoreModel,
+  navigation: NavigationProp,
+  href: string,
+  e?: Event,
+) {
+  let shouldHandle = false
+
+  if (Platform.OS !== 'web' || !e) {
+    shouldHandle = e ? !e.defaultPrevented : true
+  } else if (
+    !e.defaultPrevented && // onPress prevented default
+    // @ts-ignore Web only -prf
+    !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys
+    // @ts-ignore Web only -prf
+    (e.button == null || e.button === 0) && // ignore everything but left clicks
+    // @ts-ignore Web only -prf
+    [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc.
+  ) {
+    e.preventDefault()
+    shouldHandle = true
+  }
+
+  if (shouldHandle) {
+    href = convertBskyAppUrlIfNeeded(href)
+    if (href.startsWith('http')) {
+      Linking.openURL(href)
+    } else {
+      store.shell.closeModal() // close any active modals
+
+      // @ts-ignore we're not able to type check on this one -prf
+      navigation.dispatch(StackActions.push(...router.matchPath(href)))
+    }
   }
 }
diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx
index 182c1ba5d..ba33f92a7 100644
--- a/src/view/com/util/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/LoadLatestBtn.web.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {StyleSheet, TouchableOpacity} from 'react-native'
 import {Text} from './text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {UpIcon} from 'lib/icons'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
@@ -9,10 +10,11 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
   const pal = usePalette('default')
   return (
     <TouchableOpacity
-      style={[pal.view, styles.loadLatest]}
+      style={[pal.view, pal.borderDark, styles.loadLatest]}
       onPress={onPress}
       hitSlop={HITSLOP}>
       <Text type="md-bold" style={pal.text}>
+        <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
         Load new posts
       </Text>
     </TouchableOpacity>
@@ -29,8 +31,15 @@ const styles = StyleSheet.create({
     shadowOpacity: 0.2,
     shadowOffset: {width: 0, height: 2},
     shadowRadius: 4,
-    paddingHorizontal: 24,
+    paddingLeft: 20,
+    paddingRight: 24,
     paddingVertical: 10,
     borderRadius: 30,
+    borderWidth: 1,
+  },
+  icon: {
+    position: 'relative',
+    top: 2,
+    marginRight: 5,
   },
 })
diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
index 76b71a53d..f98a66b76 100644
--- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
+++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
@@ -25,6 +25,7 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
         authorAvatar={quote.author.avatar}
         authorHandle={quote.author.handle}
         authorDisplayName={quote.author.displayName}
+        postHref={itemHref}
         timestamp={quote.indexedAt}
       />
       <Text type="post-text" style={pal.text} numberOfLines={6}>
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index cde5a3e92..0bb402100 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
+import {DesktopWebTextLink} from './Link'
 import {ago} from 'lib/strings/time'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
@@ -12,6 +13,7 @@ interface PostMetaOpts {
   authorAvatar?: string
   authorHandle: string
   authorDisplayName: string | undefined
+  postHref: string
   timestamp: string
   did?: string
   declarationCid?: string
@@ -20,8 +22,8 @@ interface PostMetaOpts {
 
 export const PostMeta = observer(function (opts: PostMetaOpts) {
   const pal = usePalette('default')
-  let displayName = opts.authorDisplayName || opts.authorHandle
-  let handle = opts.authorHandle
+  const displayName = opts.authorDisplayName || opts.authorHandle
+  const handle = opts.authorHandle
   const store = useStores()
   const isMe = opts.did === store.me.did
   const isFollowing =
@@ -41,31 +43,35 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
   ) {
     // two-liner with follow button
     return (
-      <View style={[styles.metaTwoLine]}>
+      <View style={styles.metaTwoLine}>
         <View>
-          <Text
-            type="lg-bold"
-            style={[pal.text]}
-            numberOfLines={1}
-            lineHeight={1.2}>
-            {displayName}{' '}
-            <Text
+          <View style={styles.metaTwoLineTop}>
+            <DesktopWebTextLink
+              type="lg-bold"
+              style={pal.text}
+              numberOfLines={1}
+              lineHeight={1.2}
+              text={displayName}
+              href={`/profile/${opts.authorHandle}`}
+            />
+            <Text type="md" style={pal.textLight} lineHeight={1.2}>
+              &nbsp;&middot;&nbsp;
+            </Text>
+            <DesktopWebTextLink
               type="md"
               style={[styles.metaItem, pal.textLight]}
-              lineHeight={1.2}>
-              &middot; {ago(opts.timestamp)}
-            </Text>
-          </Text>
-          <Text
+              lineHeight={1.2}
+              text={ago(opts.timestamp)}
+              href={opts.postHref}
+            />
+          </View>
+          <DesktopWebTextLink
             type="md"
             style={[styles.metaItem, pal.textLight]}
-            lineHeight={1.2}>
-            {handle ? (
-              <Text type="md" style={[pal.textLight]}>
-                @{handle}
-              </Text>
-            ) : undefined}
-          </Text>
+            lineHeight={1.2}
+            text={`@${handle}`}
+            href={`/profile/${opts.authorHandle}`}
+          />
         </View>
 
         <View>
@@ -84,31 +90,36 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     <View style={styles.meta}>
       {typeof opts.authorAvatar !== 'undefined' && (
         <View style={[styles.metaItem, styles.avatar]}>
-          <UserAvatar
-            avatar={opts.authorAvatar}
-            handle={opts.authorHandle}
-            displayName={opts.authorDisplayName}
-            size={16}
-          />
+          <UserAvatar avatar={opts.authorAvatar} size={16} />
         </View>
       )}
       <View style={[styles.metaItem, styles.maxWidth]}>
-        <Text
+        <DesktopWebTextLink
           type="lg-bold"
-          style={[pal.text]}
+          style={pal.text}
           numberOfLines={1}
-          lineHeight={1.2}>
-          {displayName}
-          {handle ? (
-            <Text type="md" style={[pal.textLight]}>
-              &nbsp;{handle}
-            </Text>
-          ) : undefined}
-        </Text>
+          lineHeight={1.2}
+          text={
+            <>
+              {displayName}
+              <Text type="md" style={[pal.textLight]}>
+                &nbsp;{handle}
+              </Text>
+            </>
+          }
+          href={`/profile/${opts.authorHandle}`}
+        />
       </View>
-      <Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}>
-        &middot; {ago(opts.timestamp)}
+      <Text type="md" style={pal.textLight} lineHeight={1.2}>
+        &middot;&nbsp;
       </Text>
+      <DesktopWebTextLink
+        type="md"
+        style={[styles.metaItem, pal.textLight]}
+        lineHeight={1.2}
+        text={ago(opts.timestamp)}
+        href={opts.postHref}
+      />
     </View>
   )
 })
@@ -125,6 +136,10 @@ const styles = StyleSheet.create({
     justifyContent: 'space-between',
     paddingBottom: 2,
   },
+  metaTwoLineTop: {
+    flexDirection: 'row',
+    alignItems: 'baseline',
+  },
   metaItem: {
     paddingRight: 5,
   },
diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx
index d8573bd56..539a71ecf 100644
--- a/src/view/com/util/PostMuted.tsx
+++ b/src/view/com/util/PostMuted.tsx
@@ -7,7 +7,7 @@ import {Text} from './text/Text'
 export function PostMutedWrapper({
   isMuted,
   children,
-}: React.PropsWithChildren<{isMuted: boolean}>) {
+}: React.PropsWithChildren<{isMuted?: boolean}>) {
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
   if (!isMuted || override) {
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d0d2c273b..2e0632521 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg'
+import Svg, {Circle, Path} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {HighPriorityImage} from 'view/com/util/images/Image'
@@ -11,52 +11,48 @@ import {
   PickedMedia,
 } from '../../../lib/media/picker'
 import {
-  requestPhotoAccessIfNeeded,
-  requestCameraAccessIfNeeded,
-} from 'lib/permissions'
+  usePhotoLibraryPermission,
+  useCameraPermission,
+} from 'lib/hooks/usePermissions'
 import {useStores} from 'state/index'
-import {colors, gradients} from 'lib/styles'
+import {colors} from 'lib/styles'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 
+function DefaultAvatar({size}: {size: number}) {
+  return (
+    <Svg
+      width={size}
+      height={size}
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="none">
+      <Circle cx="12" cy="12" r="12" fill="#0070ff" />
+      <Circle cx="12" cy="9.5" r="3.5" fill="#fff" />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="#fff"
+        d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
+      />
+    </Svg>
+  )
+}
+
 export function UserAvatar({
   size,
-  handle,
   avatar,
-  displayName,
   onSelectNewAvatar,
 }: {
   size: number
-  handle: string
-  displayName: string | undefined
   avatar?: string | null
   onSelectNewAvatar?: (img: PickedMedia | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
-  const initials = getInitials(displayName || handle)
-
-  const renderSvg = (svgSize: number, svgInitials: string) => (
-    <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
-      <Defs>
-        <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
-          <Stop offset="0" stopColor={gradients.blue.start} stopOpacity="1" />
-          <Stop offset="1" stopColor={gradients.blue.end} stopOpacity="1" />
-        </LinearGradient>
-      </Defs>
-      <Circle cx="50" cy="50" r="50" fill="url(#grad)" />
-      <Text
-        fill="white"
-        fontSize="50"
-        fontWeight="bold"
-        x="50"
-        y="67"
-        textAnchor="middle">
-        {svgInitials}
-      </Text>
-    </Svg>
-  )
+  const {requestCameraAccessIfNeeded} = useCameraPermission()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
   const dropdownItems = [
     !isWeb && {
@@ -124,7 +120,7 @@ export function UserAvatar({
           source={{uri: avatar}}
         />
       ) : (
-        renderSvg(size, initials)
+        <DefaultAvatar size={size} />
       )}
       <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
@@ -141,26 +137,10 @@ export function UserAvatar({
       source={{uri: avatar}}
     />
   ) : (
-    renderSvg(size, initials)
+    <DefaultAvatar size={size} />
   )
 }
 
-function getInitials(str: string): string {
-  const tokens = str
-    .toLowerCase()
-    .replace(/[^a-z]/g, '')
-    .split(' ')
-    .filter(Boolean)
-    .map(v => v.trim())
-  if (tokens.length >= 2 && tokens[0][0] && tokens[0][1]) {
-    return tokens[0][0].toUpperCase() + tokens[1][0].toUpperCase()
-  }
-  if (tokens.length === 1 && tokens[0][0]) {
-    return tokens[0][0].toUpperCase()
-  }
-  return 'X'
-}
-
 const styles = StyleSheet.create({
   editButtonContainer: {
     position: 'absolute',
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 16e05311b..d89de9158 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
+import Svg, {Rect} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import Image from 'view/com/util/images/Image'
-import {colors, gradients} from 'lib/styles'
+import {colors} from 'lib/styles'
 import {
   openCamera,
   openCropper,
@@ -13,9 +13,9 @@ import {
 } from '../../../lib/media/picker'
 import {useStores} from 'state/index'
 import {
-  requestPhotoAccessIfNeeded,
-  requestCameraAccessIfNeeded,
-} from 'lib/permissions'
+  usePhotoLibraryPermission,
+  useCameraPermission,
+} from 'lib/hooks/usePermissions'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
@@ -29,6 +29,9 @@ export function UserBanner({
 }) {
   const store = useStores()
   const pal = usePalette('default')
+  const {requestCameraAccessIfNeeded} = useCameraPermission()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+
   const dropdownItems = [
     !isWeb && {
       label: 'Camera',
@@ -80,19 +83,8 @@ export function UserBanner({
   ]
 
   const renderSvg = () => (
-    <Svg width="100%" height="150" viewBox="50 0 200 100">
-      <Defs>
-        <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
-          <Stop
-            offset="0"
-            stopColor={gradients.blueDark.start}
-            stopOpacity="1"
-          />
-          <Stop offset="1" stopColor={gradients.blueDark.end} stopOpacity="1" />
-        </LinearGradient>
-      </Defs>
-      <Rect x="0" y="0" width="400" height="100" fill="url(#grad)" />
-      <Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" />
+    <Svg width="100%" height="150" viewBox="0 0 400 100">
+      <Rect x="0" y="0" width="400" height="100" fill="#0070ff" />
     </Svg>
   )
 
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index 84170b3bf..4753c9b01 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -1,7 +1,7 @@
 import React, {useState, useEffect} from 'react'
 import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
-import {Link} from './Link'
+import {DesktopWebTextLink} from './Link'
 import {Text} from './text/Text'
 import {LoadingPlaceholder} from './LoadingPlaceholder'
 import {useStores} from 'state/index'
@@ -14,7 +14,6 @@ export function UserInfoText({
   failed,
   prefix,
   style,
-  asLink,
 }: {
   type?: TypographyVariant
   did: string
@@ -23,7 +22,6 @@ export function UserInfoText({
   failed?: string
   prefix?: string
   style?: StyleProp<TextStyle>
-  asLink?: boolean
 }) {
   attr = attr || 'handle'
   failed = failed || 'user'
@@ -64,9 +62,14 @@ export function UserInfoText({
     )
   } else if (profile) {
     inner = (
-      <Text type={type} style={style} lineHeight={1.2} numberOfLines={1}>{`${
-        prefix || ''
-      }${profile[attr] || profile.handle}`}</Text>
+      <DesktopWebTextLink
+        type={type}
+        style={style}
+        lineHeight={1.2}
+        numberOfLines={1}
+        href={`/profile/${profile.handle}`}
+        text={`${prefix || ''}${profile[attr] || profile.handle}`}
+      />
     )
   } else {
     inner = (
@@ -78,17 +81,6 @@ export function UserInfoText({
     )
   }
 
-  if (asLink) {
-    const title = profile?.displayName || profile?.handle || 'User'
-    return (
-      <Link
-        href={`/profile/${profile?.handle ? profile.handle : did}`}
-        title={title}>
-        {inner}
-      </Link>
-    )
-  }
-
   return inner
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index ffd1b1d63..a99282512 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -2,17 +2,19 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
 import {UserAvatar} from './UserAvatar'
 import {Text} from './text/Text'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useAnalytics} from 'lib/analytics'
-import {isDesktopWeb} from '../../../platform/detection'
+import {NavigationProp} from 'lib/routes/types'
+import {isDesktopWeb} from 'platform/detection'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
-export const ViewHeader = observer(function ViewHeader({
+export const ViewHeader = observer(function ({
   title,
   canGoBack,
   hideOnScroll,
@@ -23,50 +25,55 @@ export const ViewHeader = observer(function ViewHeader({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const onPressBack = () => {
-    store.nav.tab.goBack()
-  }
-  const onPressMenu = () => {
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressMenu = React.useCallback(() => {
     track('ViewHeader:MenuButtonClicked')
-    store.shell.setMainMenuOpen(true)
-  }
-  if (typeof canGoBack === 'undefined') {
-    canGoBack = store.nav.tab.canGoBack
-  }
+    store.shell.openDrawer()
+  }, [track, store])
+
   if (isDesktopWeb) {
     return <></>
+  } else {
+    if (typeof canGoBack === 'undefined') {
+      canGoBack = navigation.canGoBack()
+    }
+
+    return (
+      <Container hideOnScroll={hideOnScroll || false}>
+        <TouchableOpacity
+          testID="viewHeaderBackOrMenuBtn"
+          onPress={canGoBack ? onPressBack : onPressMenu}
+          hitSlop={BACK_HITSLOP}
+          style={canGoBack ? styles.backBtn : styles.backBtnWide}>
+          {canGoBack ? (
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              style={[styles.backIcon, pal.text]}
+            />
+          ) : (
+            <UserAvatar size={30} avatar={store.me.avatar} />
+          )}
+        </TouchableOpacity>
+        <View style={styles.titleContainer} pointerEvents="none">
+          <Text type="title" style={[pal.text, styles.title]}>
+            {title}
+          </Text>
+        </View>
+        <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+      </Container>
+    )
   }
-  return (
-    <Container hideOnScroll={hideOnScroll || false}>
-      <TouchableOpacity
-        testID="viewHeaderBackOrMenuBtn"
-        onPress={canGoBack ? onPressBack : onPressMenu}
-        hitSlop={BACK_HITSLOP}
-        style={canGoBack ? styles.backBtn : styles.backBtnWide}>
-        {canGoBack ? (
-          <FontAwesomeIcon
-            size={18}
-            icon="angle-left"
-            style={[styles.backIcon, pal.text]}
-          />
-        ) : (
-          <UserAvatar
-            size={30}
-            handle={store.me.handle}
-            displayName={store.me.displayName}
-            avatar={store.me.avatar}
-          />
-        )}
-      </TouchableOpacity>
-      <View style={styles.titleContainer} pointerEvents="none">
-        <Text type="title" style={[pal.text, styles.title]}>
-          {title}
-        </Text>
-      </View>
-      <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
-    </Container>
-  )
 })
 
 const Container = observer(
@@ -119,8 +126,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingHorizontal: 12,
-    paddingTop: 6,
-    paddingBottom: 6,
+    paddingVertical: 6,
   },
   headerFloating: {
     position: 'absolute',
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 8b5adaa04..9a43697b5 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -23,7 +23,6 @@ import {
   ViewProps,
 } from 'react-native'
 import {addStyle, colors} from 'lib/styles'
-import {DESKTOP_HEADER_HEIGHT} from 'lib/constants'
 
 export function CenteredView({
   style,
@@ -73,14 +72,14 @@ export const ScrollView = React.forwardRef(function (
 const styles = StyleSheet.create({
   container: {
     width: '100%',
-    maxWidth: 550,
+    maxWidth: 600,
     marginLeft: 'auto',
     marginRight: 'auto',
   },
   containerScroll: {
     width: '100%',
-    height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`,
-    maxWidth: 550,
+    minHeight: '100vh',
+    maxWidth: 600,
     marginLeft: 'auto',
     marginRight: 'auto',
   },
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index ac83d1a54..d6ae800c6 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -17,7 +17,6 @@ import {Button, ButtonType} from './Button'
 import {colors} from 'lib/styles'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useStores} from 'state/index'
-import {TABS_ENABLED} from 'lib/build-flags'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 
@@ -138,15 +137,6 @@ export function PostDropdownBtn({
   const store = useStores()
 
   const dropdownItems: DropdownItem[] = [
-    TABS_ENABLED
-      ? {
-          icon: ['far', 'clone'],
-          label: 'Open in new tab',
-          onPress() {
-            store.nav.newTab(itemHref)
-          },
-        }
-      : undefined,
     {
       icon: 'language',
       label: 'Translate...',
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index 57a875cd3..d6b2bb119 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -41,6 +41,9 @@ export function RadioButton({
     'secondary-light': {
       borderColor: theme.palette.secondary.border,
     },
+    default: {
+      borderColor: theme.palette.default.border,
+    },
     'default-light': {
       borderColor: theme.palette.default.border,
     },
@@ -69,6 +72,9 @@ export function RadioButton({
       'secondary-light': {
         backgroundColor: theme.palette.secondary.background,
       },
+      default: {
+        backgroundColor: theme.palette.primary.background,
+      },
       'default-light': {
         backgroundColor: theme.palette.primary.background,
       },
@@ -103,6 +109,10 @@ export function RadioButton({
       color: theme.palette.secondary.textInverted,
       fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
     },
+    default: {
+      color: theme.palette.default.text,
+      fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
+    },
     'default-light': {
       color: theme.palette.default.text,
       fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx
index 005d1165e..a6e0ba3fe 100644
--- a/src/view/com/util/forms/ToggleButton.tsx
+++ b/src/view/com/util/forms/ToggleButton.tsx
@@ -42,6 +42,9 @@ export function ToggleButton({
     'secondary-light': {
       borderColor: theme.palette.secondary.border,
     },
+    default: {
+      borderColor: theme.palette.default.border,
+    },
     'default-light': {
       borderColor: theme.palette.default.border,
     },
@@ -77,6 +80,11 @@ export function ToggleButton({
         backgroundColor: theme.palette.secondary.background,
         opacity: isSelected ? 1 : 0.5,
       },
+      default: {
+        backgroundColor: isSelected
+          ? theme.palette.primary.background
+          : colors.gray3,
+      },
       'default-light': {
         backgroundColor: isSelected
           ? theme.palette.primary.background
@@ -113,6 +121,10 @@ export function ToggleButton({
       color: theme.palette.secondary.textInverted,
       fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
     },
+    default: {
+      color: theme.palette.default.text,
+      fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
+    },
     'default-light': {
       color: theme.palette.default.text,
       fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
diff --git a/src/view/routes.ts b/src/view/routes.ts
deleted file mode 100644
index 1cd9ef8e2..000000000
--- a/src/view/routes.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import React from 'react'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
-import {Home} from './screens/Home'
-import {Contacts} from './screens/Contacts'
-import {Search} from './screens/Search'
-import {Notifications} from './screens/Notifications'
-import {NotFound} from './screens/NotFound'
-import {PostThread} from './screens/PostThread'
-import {PostUpvotedBy} from './screens/PostUpvotedBy'
-import {PostDownvotedBy} from './screens/PostDownvotedBy'
-import {PostRepostedBy} from './screens/PostRepostedBy'
-import {Profile} from './screens/Profile'
-import {ProfileFollowers} from './screens/ProfileFollowers'
-import {ProfileFollows} from './screens/ProfileFollows'
-import {Settings} from './screens/Settings'
-import {Debug} from './screens/Debug'
-import {Log} from './screens/Log'
-
-export type ScreenParams = {
-  navIdx: string
-  params: Record<string, any>
-  visible: boolean
-}
-export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp]
-export type MatchResult = {
-  Com: React.FC<ScreenParams>
-  defaultTitle: string
-  icon: IconProp
-  params: Record<string, any>
-  isNotFound?: boolean
-}
-
-const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i')
-export const routes: Route[] = [
-  [Home, 'Home', 'house', r('/')],
-  [Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')],
-  [Search, 'Search', 'magnifying-glass', r('/search')],
-  [Notifications, 'Notifications', 'bell', r('/notifications')],
-  [Settings, 'Settings', 'bell', r('/settings')],
-  [Profile, 'User', ['far', 'user'], r('/profile/(?<name>[^/]+)')],
-  [
-    ProfileFollowers,
-    'Followers',
-    'users',
-    r('/profile/(?<name>[^/]+)/followers'),
-  ],
-  [ProfileFollows, 'Follows', 'users', r('/profile/(?<name>[^/]+)/follows')],
-  [
-    PostThread,
-    'Post',
-    ['far', 'message'],
-    r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)'),
-  ],
-  [
-    PostUpvotedBy,
-    'Liked by',
-    'heart',
-    r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/upvoted-by'),
-  ],
-  [
-    PostDownvotedBy,
-    'Downvoted by',
-    'heart',
-    r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/downvoted-by'),
-  ],
-  [
-    PostRepostedBy,
-    'Reposted by',
-    'retweet',
-    r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/reposted-by'),
-  ],
-  [Debug, 'Debug', 'house', r('/sys/debug')],
-  [Log, 'Log', 'house', r('/sys/log')],
-]
-
-export function match(url: string): MatchResult {
-  for (const [Com, defaultTitle, icon, pattern] of routes) {
-    const res = pattern.exec(url)
-    if (res) {
-      // TODO: query params
-      return {Com, defaultTitle, icon, params: res.groups || {}}
-    }
-  }
-  return {
-    Com: NotFound,
-    defaultTitle: 'Not found',
-    icon: 'magnifying-glass',
-    params: {},
-    isNotFound: true,
-  }
-}
diff --git a/src/view/screens/Contacts.tsx b/src/view/screens/Contacts.tsx
deleted file mode 100644
index 21943a10a..000000000
--- a/src/view/screens/Contacts.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React, {useEffect, useState, useRef} from 'react'
-import {StyleSheet, TextInput, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
-import {Selector} from '../com/util/Selector'
-import {Text} from '../com/util/text/Text'
-import {colors} from 'lib/styles'
-import {ScreenParams} from '../routes'
-import {useStores} from 'state/index'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-
-export const Contacts = ({navIdx, visible}: ScreenParams) => {
-  const store = useStores()
-  const selectorInterp = useAnimatedValue(0)
-
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, 'Contacts')
-    }
-  }, [store, visible, navIdx])
-
-  const [searchText, onChangeSearchText] = useState('')
-  const inputRef = useRef<TextInput | null>(null)
-
-  return (
-    <View>
-      <View style={styles.section}>
-        <Text testID="contactsTitle" style={styles.title}>
-          Contacts
-        </Text>
-      </View>
-      <View style={styles.section}>
-        <View style={styles.searchContainer}>
-          <FontAwesomeIcon
-            icon="magnifying-glass"
-            size={16}
-            style={styles.searchIcon}
-          />
-          <TextInput
-            testID="contactsTextInput"
-            ref={inputRef}
-            value={searchText}
-            style={styles.searchInput}
-            placeholder="Search"
-            placeholderTextColor={colors.gray4}
-            onChangeText={onChangeSearchText}
-          />
-        </View>
-      </View>
-      <Selector
-        items={['All', 'Following', 'Scenes']}
-        selectedIndex={0}
-        panX={selectorInterp}
-      />
-      {!!store.me.handle && <ProfileFollowsComponent name={store.me.handle} />}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  section: {
-    backgroundColor: colors.white,
-  },
-  title: {
-    fontSize: 30,
-    fontWeight: 'bold',
-    paddingHorizontal: 12,
-    paddingVertical: 6,
-  },
-
-  searchContainer: {
-    flexDirection: 'row',
-    backgroundColor: colors.gray1,
-    paddingHorizontal: 8,
-    paddingVertical: 8,
-    marginHorizontal: 10,
-    marginBottom: 6,
-    borderRadius: 4,
-  },
-  searchIcon: {
-    color: colors.gray5,
-    marginRight: 8,
-  },
-  searchInput: {
-    flex: 1,
-    color: colors.black,
-  },
-})
diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx
index eb5ffe20f..852025324 100644
--- a/src/view/screens/Debug.tsx
+++ b/src/view/screens/Debug.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {ScrollView, View} from 'react-native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -20,7 +21,10 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
 
 const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs']
 
-export const Debug = () => {
+export const DebugScreen = ({}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'Debug'
+>) => {
   const [colorScheme, setColorScheme] = React.useState<'light' | 'dark'>(
     'light',
   )
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 42759f7ff..505b1fcfe 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,14 +1,15 @@
 import React from 'react'
 import {FlatList, View} from 'react-native'
+import {useFocusEffect, useIsFocused} from '@react-navigation/native'
 import {observer} from 'mobx-react-lite'
 import useAppState from 'react-native-appstate-hook'
+import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/posts/Feed'
 import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
 import {WelcomeBanner} from '../com/util/WelcomeBanner'
 import {FAB} from '../com/util/FAB'
 import {useStores} from 'state/index'
-import {ScreenParams} from '../routes'
 import {s} from 'lib/styles'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics'
@@ -16,19 +17,20 @@ import {ComposeIcon2} from 'lib/icons'
 
 const HEADER_HEIGHT = 42
 
-export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
+type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
+export const HomeScreen = observer(function Home(_opts: Props) {
   const store = useStores()
   const onMainScroll = useOnMainScroll(store)
   const {screen, track} = useAnalytics()
   const scrollElRef = React.useRef<FlatList>(null)
-  const [wasVisible, setWasVisible] = React.useState<boolean>(false)
   const {appState} = useAppState({
     onForeground: () => doPoll(true),
   })
+  const isFocused = useIsFocused()
 
   const doPoll = React.useCallback(
     (knownActive = false) => {
-      if ((!knownActive && appState !== 'active') || !visible) {
+      if ((!knownActive && appState !== 'active') || !isFocused) {
         return
       }
       if (store.me.mainFeed.isLoading) {
@@ -37,7 +39,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
       store.log.debug('HomeScreen: Polling for new posts')
       store.me.mainFeed.checkForLatest()
     },
-    [appState, visible, store],
+    [appState, isFocused, store],
   )
 
   const scrollToTop = React.useCallback(() => {
@@ -46,53 +48,35 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
     scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
   }, [scrollElRef])
 
-  React.useEffect(() => {
-    const softResetSub = store.onScreenSoftReset(scrollToTop)
-    const feedCleanup = store.me.mainFeed.registerListeners()
-    const pollInterval = setInterval(doPoll, 15e3)
-    const cleanup = () => {
-      clearInterval(pollInterval)
-      softResetSub.remove()
-      feedCleanup()
-    }
+  useFocusEffect(
+    React.useCallback(() => {
+      const softResetSub = store.onScreenSoftReset(scrollToTop)
+      const feedCleanup = store.me.mainFeed.registerListeners()
+      const pollInterval = setInterval(doPoll, 15e3)
 
-    // guard to only continue when transitioning from !visible -> visible
-    // TODO is this 100% needed? depends on if useEffect() is getting refired
-    //      for reasons other than `visible` changing -prf
-    if (!visible) {
-      setWasVisible(false)
-      return cleanup
-    } else if (wasVisible) {
-      return cleanup
-    }
-    setWasVisible(true)
+      screen('Feed')
+      store.log.debug('HomeScreen: Updating feed')
+      if (store.me.mainFeed.hasContent) {
+        store.me.mainFeed.update()
+      }
 
-    // just became visible
-    screen('Feed')
-    store.nav.setTitle(navIdx, 'Home')
-    store.log.debug('HomeScreen: Updating feed')
-    if (store.me.mainFeed.hasContent) {
-      store.me.mainFeed.update()
-    }
-    return cleanup
-  }, [
-    visible,
-    store,
-    store.me.mainFeed,
-    navIdx,
-    doPoll,
-    wasVisible,
-    scrollToTop,
-    screen,
-  ])
+      return () => {
+        clearInterval(pollInterval)
+        softResetSub.remove()
+        feedCleanup()
+      }
+    }, [store, doPoll, scrollToTop, screen]),
+  )
 
   const onPressCompose = React.useCallback(() => {
     track('HomeScreen:PressCompose')
     store.shell.openComposer({})
   }, [store, track])
+
   const onPressTryAgain = React.useCallback(() => {
     store.me.mainFeed.refresh()
   }, [store])
+
   const onPressLoadLatest = React.useCallback(() => {
     store.me.mainFeed.refresh()
     scrollToTop()
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index c067d3506..8e0fe8dd3 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -1,28 +1,30 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ScrollView} from '../com/util/Views'
 import {useStores} from 'state/index'
-import {ScreenParams} from '../routes'
 import {s} from 'lib/styles'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ago} from 'lib/strings/time'
 
-export const Log = observer(function Log({navIdx, visible}: ScreenParams) {
+export const LogScreen = observer(function Log({}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'Log'
+>) {
   const pal = usePalette('default')
   const store = useStores()
   const [expanded, setExpanded] = React.useState<string[]>([])
 
-  useEffect(() => {
-    if (!visible) {
-      return
-    }
-    store.shell.setMinimalShellMode(false)
-    store.nav.setTitle(navIdx, 'Log')
-  }, [visible, store, navIdx])
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
+    }, [store]),
+  )
 
   const toggler = (id: string) => () => {
     if (expanded.includes(id)) {
diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx
index 77bbdd2aa..6ab37f117 100644
--- a/src/view/screens/NotFound.tsx
+++ b/src/view/screens/NotFound.tsx
@@ -1,20 +1,41 @@
 import React from 'react'
-import {Button, StyleSheet, View} from 'react-native'
+import {StyleSheet, View} from 'react-native'
+import {useNavigation, StackActions} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
-import {useStores} from 'state/index'
+import {Button} from 'view/com/util/forms/Button'
+import {NavigationProp} from 'lib/routes/types'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+export const NotFoundScreen = () => {
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const canGoBack = navigation.canGoBack()
+  const onPressHome = React.useCallback(() => {
+    if (canGoBack) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+      navigation.dispatch(StackActions.popToTop())
+    }
+  }, [navigation, canGoBack])
 
-export const NotFound = () => {
-  const stores = useStores()
   return (
-    <View testID="notFoundView">
+    <View testID="notFoundView" style={pal.view}>
       <ViewHeader title="Page not found" />
       <View style={styles.container}>
-        <Text style={styles.title}>Page not found</Text>
+        <Text type="title-2xl" style={[pal.text, s.mb10]}>
+          Page not found
+        </Text>
+        <Text type="md" style={[pal.text, s.mb10]}>
+          We're sorry! We can't find the page you were looking for.
+        </Text>
         <Button
-          testID="navigateHomeButton"
-          title="Home"
-          onPress={() => stores.nav.navigate('/')}
+          type="primary"
+          label={canGoBack ? 'Go back' : 'Go home'}
+          onPress={onPressHome}
         />
       </View>
     </View>
@@ -23,12 +44,9 @@ export const NotFound = () => {
 
 const styles = StyleSheet.create({
   container: {
-    justifyContent: 'center',
-    alignItems: 'center',
     paddingTop: 100,
-  },
-  title: {
-    fontSize: 40,
-    fontWeight: 'bold',
+    paddingHorizontal: 20,
+    alignItems: 'center',
+    height: '100%',
   },
 })
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index f1a9e8bf0..492177d1f 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,17 +1,25 @@
 import React, {useEffect} from 'react'
 import {FlatList, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
 import useAppState from 'react-native-appstate-hook'
+import {
+  NativeStackScreenProps,
+  NotificationsTabNavigatorParams,
+} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
 import {useStores} from 'state/index'
-import {ScreenParams} from '../routes'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
 
 const NOTIFICATIONS_POLL_INTERVAL = 15e3
 
-export const Notifications = ({navIdx, visible}: ScreenParams) => {
+type Props = NativeStackScreenProps<
+  NotificationsTabNavigatorParams,
+  'Notifications'
+>
+export const NotificationsScreen = ({}: Props) => {
   const store = useStores()
   const onMainScroll = useOnMainScroll(store)
   const scrollElRef = React.useRef<FlatList>(null)
@@ -59,21 +67,19 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
 
   // on-visible setup
   // =
-  useEffect(() => {
-    if (!visible) {
-      // mark read when the user leaves the screen
-      store.me.notifications.markAllRead()
-      return
-    }
-    store.log.debug('NotificationsScreen: Updating feed')
-    const softResetSub = store.onScreenSoftReset(scrollToTop)
-    store.me.notifications.update()
-    screen('Notifications')
-    store.nav.setTitle(navIdx, 'Notifications')
-    return () => {
-      softResetSub.remove()
-    }
-  }, [visible, store, navIdx, screen, scrollToTop])
+  useFocusEffect(
+    React.useCallback(() => {
+      store.log.debug('NotificationsScreen: Updating feed')
+      const softResetSub = store.onScreenSoftReset(scrollToTop)
+      store.me.notifications.update()
+      screen('Notifications')
+
+      return () => {
+        softResetSub.remove()
+        store.me.notifications.markAllRead()
+      }
+    }, [store, screen, scrollToTop]),
+  )
 
   return (
     <View style={s.hContentRegion}>
diff --git a/src/view/screens/PostDownvotedBy.tsx b/src/view/screens/PostDownvotedBy.tsx
deleted file mode 100644
index 570482598..000000000
--- a/src/view/screens/PostDownvotedBy.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React, {useEffect} from 'react'
-import {View} from 'react-native'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
-import {ScreenParams} from '../routes'
-import {useStores} from 'state/index'
-import {makeRecordUri} from 'lib/strings/url-helpers'
-
-export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => {
-  const store = useStores()
-  const {name, rkey} = params
-  const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
-
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, 'Downvoted by')
-      store.shell.setMinimalShellMode(false)
-    }
-  }, [store, visible, navIdx])
-
-  return (
-    <View>
-      <ViewHeader title="Downvoted by" />
-      <PostLikedByComponent uri={uri} direction="down" />
-    </View>
-  )
-}
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx
index 4be4b4b42..1a63445e5 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/view/screens/PostRepostedBy.tsx
@@ -1,22 +1,23 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 
-export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
+export const PostRepostedByScreen = ({route}: Props) => {
   const store = useStores()
-  const {name, rkey} = params
+  const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
 
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, 'Reposted by')
+  useFocusEffect(
+    React.useCallback(() => {
       store.shell.setMinimalShellMode(false)
-    }
-  }, [store, visible, navIdx])
+    }, [store]),
+  )
 
   return (
     <View>
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 0b6829735..0e9feae0b 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,58 +1,45 @@
-import React, {useEffect, useMemo} from 'react'
+import React, {useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
 import {ComposePrompt} from 'view/com/composer/Prompt'
 import {PostThreadViewModel} from 'state/models/post-thread-view'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lodash'
+import {isDesktopWeb} from 'platform/detection'
 
 const SHELL_FOOTER_HEIGHT = 44
 
-export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
+export const PostThreadScreen = ({route}: Props) => {
   const store = useStores()
   const safeAreaInsets = useSafeAreaInsets()
-  const {name, rkey} = params
+  const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
   const view = useMemo<PostThreadViewModel>(
     () => new PostThreadViewModel(store, {uri}),
     [store, uri],
   )
 
-  useEffect(() => {
-    let aborted = false
-    const threadCleanup = view.registerListeners()
-    const setTitle = () => {
-      const author = view.thread?.post.author
-      const niceName = author?.handle || name
-      store.nav.setTitle(navIdx, `Post by ${niceName}`)
-    }
-    if (!visible) {
-      return threadCleanup
-    }
-    setTitle()
-    store.shell.setMinimalShellMode(false)
-    if (!view.hasLoaded && !view.isLoading) {
-      view.setup().then(
-        () => {
-          if (!aborted) {
-            setTitle()
-          }
-        },
-        err => {
+  useFocusEffect(
+    React.useCallback(() => {
+      const threadCleanup = view.registerListeners()
+      store.shell.setMinimalShellMode(false)
+      if (!view.hasLoaded && !view.isLoading) {
+        view.setup().catch(err => {
           store.log.error('Failed to fetch thread', err)
-        },
-      )
-    }
-    return () => {
-      aborted = true
-      threadCleanup()
-    }
-  }, [visible, store.nav, store.log, store.shell, name, navIdx, view])
+        })
+      }
+      return () => {
+        threadCleanup()
+      }
+    }, [store, view]),
+  )
 
   const onPressReply = React.useCallback(() => {
     if (!view.thread) {
@@ -77,15 +64,24 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
     <View style={s.hContentRegion}>
       <ViewHeader title="Post" />
       <View style={s.hContentRegion}>
-        <PostThreadComponent uri={uri} view={view} />
-      </View>
-      <View
-        style={[
-          styles.prompt,
-          {bottom: SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30)},
-        ]}>
-        <ComposePrompt onPressCompose={onPressReply} />
+        <PostThreadComponent
+          uri={uri}
+          view={view}
+          onPressReply={onPressReply}
+        />
       </View>
+      {!isDesktopWeb && (
+        <View
+          style={[
+            styles.prompt,
+            {
+              bottom:
+                SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30),
+            },
+          ]}>
+          <ComposePrompt onPressCompose={onPressReply} />
+        </View>
+      )}
     </View>
   )
 }
diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostUpvotedBy.tsx
index 4d6ad4114..b1690721b 100644
--- a/src/view/screens/PostUpvotedBy.tsx
+++ b/src/view/screens/PostUpvotedBy.tsx
@@ -1,21 +1,23 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 
-export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'>
+export const PostUpvotedByScreen = ({route}: Props) => {
   const store = useStores()
-  const {name, rkey} = params
+  const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
 
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, 'Liked by')
-    }
-  }, [store, visible, navIdx])
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
+    }, [store]),
+  )
 
   return (
     <View>
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index fa0c04106..e0d0a5884 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,9 +1,10 @@
 import React, {useEffect, useState} from 'react'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewSelector} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
-import {ScreenParams} from '../routes'
 import {ProfileUiModel, Sections} from 'state/models/profile-ui'
 import {useStores} from 'state/index'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
@@ -23,7 +24,8 @@ const LOADING_ITEM = {_reactKey: '__loading__'}
 const END_ITEM = {_reactKey: '__end__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
 
-export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
+export const ProfileScreen = observer(({route}: Props) => {
   const store = useStores()
   const {screen, track} = useAnalytics()
 
@@ -34,35 +36,30 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
   const onMainScroll = useOnMainScroll(store)
   const [hasSetup, setHasSetup] = useState<boolean>(false)
   const uiState = React.useMemo(
-    () => new ProfileUiModel(store, {user: params.name}),
-    [params.name, store],
+    () => new ProfileUiModel(store, {user: route.params.name}),
+    [route.params.name, store],
   )
 
-  useEffect(() => {
-    store.nav.setTitle(navIdx, params.name)
-  }, [store, navIdx, params.name])
-
-  useEffect(() => {
-    let aborted = false
-    const feedCleanup = uiState.feed.registerListeners()
-    if (!visible) {
-      return feedCleanup
-    }
-    if (hasSetup) {
-      uiState.update()
-    } else {
-      uiState.setup().then(() => {
-        if (aborted) {
-          return
-        }
-        setHasSetup(true)
-      })
-    }
-    return () => {
-      aborted = true
-      feedCleanup()
-    }
-  }, [visible, store, hasSetup, uiState])
+  useFocusEffect(
+    React.useCallback(() => {
+      let aborted = false
+      const feedCleanup = uiState.feed.registerListeners()
+      if (hasSetup) {
+        uiState.update()
+      } else {
+        uiState.setup().then(() => {
+          if (aborted) {
+            return
+          }
+          setHasSetup(true)
+        })
+      }
+      return () => {
+        aborted = true
+        feedCleanup()
+      }
+    }, [hasSetup, uiState]),
+  )
 
   // events
   // =
@@ -171,7 +168,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
         <ErrorScreen
           testID="profileErrorScreen"
           title="Failed to load profile"
-          message={`There was an issue when attempting to load ${params.name}`}
+          message={`There was an issue when attempting to load ${route.params.name}`}
           details={uiState.profile.error}
           onPressTryAgain={onPressTryAgain}
         />
diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx
index 9f1a9c741..b248cdc3a 100644
--- a/src/view/screens/ProfileFollowers.tsx
+++ b/src/view/screens/ProfileFollowers.tsx
@@ -1,20 +1,21 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 
-export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'>
+export const ProfileFollowersScreen = ({route}: Props) => {
   const store = useStores()
-  const {name} = params
+  const {name} = route.params
 
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, `Followers of ${name}`)
+  useFocusEffect(
+    React.useCallback(() => {
       store.shell.setMinimalShellMode(false)
-    }
-  }, [store, visible, name, navIdx])
+    }, [store]),
+  )
 
   return (
     <View>
diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx
index 1cdb5bccf..7edf8edba 100644
--- a/src/view/screens/ProfileFollows.tsx
+++ b/src/view/screens/ProfileFollows.tsx
@@ -1,20 +1,21 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 
-export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'>
+export const ProfileFollowsScreen = ({route}: Props) => {
   const store = useStores()
-  const {name} = params
+  const {name} = route.params
 
-  useEffect(() => {
-    if (visible) {
-      store.nav.setTitle(navIdx, `Followed by ${name}`)
+  useFocusEffect(
+    React.useCallback(() => {
       store.shell.setMinimalShellMode(false)
-    }
-  }, [store, visible, name, navIdx])
+    }, [store]),
+  )
 
   return (
     <View>
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
index a87c41e76..a50d5c6a7 100644
--- a/src/view/screens/Search.tsx
+++ b/src/view/screens/Search.tsx
@@ -7,12 +7,19 @@ import {
   TouchableWithoutFeedback,
   View,
 } from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useFocusEffect} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 import {ScrollView} from '../com/util/Views'
+import {
+  NativeStackScreenProps,
+  SearchTabNavigatorParams,
+} from 'lib/routes/types'
 import {observer} from 'mobx-react-lite'
 import {UserAvatar} from '../com/util/UserAvatar'
 import {Text} from '../com/util/text/Text'
-import {ScreenParams} from '../routes'
 import {useStores} from 'state/index'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
 import {s} from 'lib/styles'
@@ -21,14 +28,17 @@ import {WhoToFollow} from '../com/discover/WhoToFollow'
 import {SuggestedPosts} from '../com/discover/SuggestedPosts'
 import {ProfileCard} from '../com/profile/ProfileCard'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics'
 
 const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
 const FIVE_MIN = 5 * 60 * 1e3
 
-export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
+type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
+export const SearchScreen = observer<Props>(({}: Props) => {
   const pal = usePalette('default')
+  const theme = useTheme()
   const store = useStores()
   const {track} = useAnalytics()
   const scrollElRef = React.useRef<ScrollView>(null)
@@ -41,33 +51,32 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
     () => new UserAutocompleteViewModel(store),
     [store],
   )
-  const {name} = params
 
   const onSoftReset = () => {
     scrollElRef.current?.scrollTo({x: 0, y: 0})
   }
 
-  React.useEffect(() => {
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const cleanup = () => {
-      softResetSub.remove()
-    }
+  useFocusEffect(
+    React.useCallback(() => {
+      const softResetSub = store.onScreenSoftReset(onSoftReset)
+      const cleanup = () => {
+        softResetSub.remove()
+      }
 
-    if (visible) {
       const now = Date.now()
       if (now - lastRenderTime > FIVE_MIN) {
         setRenderTime(Date.now()) // trigger reload of suggestions
       }
       store.shell.setMinimalShellMode(false)
       autocompleteView.setup()
-      store.nav.setTitle(navIdx, 'Search')
-    }
-    return cleanup
-  }, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
+
+      return cleanup
+    }, [store, autocompleteView, lastRenderTime, setRenderTime]),
+  )
 
   const onPressMenu = () => {
     track('ViewHeader:MenuButtonClicked')
-    store.shell.setMainMenuOpen(true)
+    store.shell.openDrawer()
   }
 
   const onChangeQuery = (text: string) => {
@@ -102,12 +111,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
             onPress={onPressMenu}
             hitSlop={MENU_HITSLOP}
             style={styles.headerMenuBtn}>
-            <UserAvatar
-              size={30}
-              handle={store.me.handle}
-              displayName={store.me.displayName}
-              avatar={store.me.avatar}
-            />
+            <UserAvatar size={30} avatar={store.me.avatar} />
           </TouchableOpacity>
           <View
             style={[
@@ -127,13 +131,18 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
               returnKeyType="search"
               value={query}
               style={[pal.text, styles.headerSearchInput]}
+              keyboardAppearance={theme.colorScheme}
               onFocus={() => setIsInputFocused(true)}
               onBlur={() => setIsInputFocused(false)}
               onChangeText={onChangeQuery}
             />
             {query ? (
               <TouchableOpacity onPress={onPressClearQuery}>
-                <FontAwesomeIcon icon="xmark" size={16} style={pal.textLight} />
+                <FontAwesomeIcon
+                  icon="xmark"
+                  size={16}
+                  style={pal.textLight as FontAwesomeIconStyle}
+                />
               </TouchableOpacity>
             ) : undefined}
           </View>
diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx
index 886d49af7..75b5f01ce 100644
--- a/src/view/screens/Search.web.tsx
+++ b/src/view/screens/Search.web.tsx
@@ -1,8 +1,12 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
 import {ScrollView} from '../com/util/Views'
 import {observer} from 'mobx-react-lite'
-import {ScreenParams} from '../routes'
+import {
+  NativeStackScreenProps,
+  SearchTabNavigatorParams,
+} from 'lib/routes/types'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {WhoToFollow} from '../com/discover/WhoToFollow'
@@ -12,7 +16,8 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 
 const FIVE_MIN = 5 * 60 * 1e3
 
-export const Search = observer(({navIdx, visible}: ScreenParams) => {
+type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
+export const SearchScreen = observer(({}: Props) => {
   const pal = usePalette('default')
   const store = useStores()
   const scrollElRef = React.useRef<ScrollView>(null)
@@ -23,22 +28,21 @@ export const Search = observer(({navIdx, visible}: ScreenParams) => {
     scrollElRef.current?.scrollTo({x: 0, y: 0})
   }
 
-  React.useEffect(() => {
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const cleanup = () => {
-      softResetSub.remove()
-    }
+  useFocusEffect(
+    React.useCallback(() => {
+      const softResetSub = store.onScreenSoftReset(onSoftReset)
 
-    if (visible) {
       const now = Date.now()
       if (now - lastRenderTime > FIVE_MIN) {
         setRenderTime(Date.now()) // trigger reload of suggestions
       }
       store.shell.setMinimalShellMode(false)
-      store.nav.setTitle(navIdx, 'Search')
-    }
-    return cleanup
-  }, [store, visible, navIdx, lastRenderTime])
+
+      return () => {
+        softResetSub.remove()
+      }
+    }, [store, lastRenderTime, setRenderTime]),
+  )
 
   return (
     <ScrollView
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 47e76a124..2e5d2c001 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {
   ActivityIndicator,
   StyleSheet,
@@ -6,13 +6,18 @@ import {
   View,
 } from 'react-native'
 import {
+  useFocusEffect,
+  useNavigation,
+  StackActions,
+} from '@react-navigation/native'
+import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {observer} from 'mobx-react-lite'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import * as AppInfo from 'lib/app-info'
 import {useStores} from 'state/index'
-import {ScreenParams} from '../routes'
 import {s, colors} from 'lib/styles'
 import {ScrollView} from '../com/util/Views'
 import {ViewHeader} from '../com/util/ViewHeader'
@@ -25,41 +30,38 @@ import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {AccountData} from 'state/models/session'
 import {useAnalytics} from 'lib/analytics'
+import {NavigationProp} from 'lib/routes/types'
 
-export const Settings = observer(function Settings({
-  navIdx,
-  visible,
-}: ScreenParams) {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
+export const SettingsScreen = observer(function Settings({}: Props) {
   const theme = useTheme()
   const pal = usePalette('default')
   const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
   const {screen, track} = useAnalytics()
   const [isSwitching, setIsSwitching] = React.useState(false)
 
-  useEffect(() => {
-    screen('Settings')
-  }, [screen])
-
-  useEffect(() => {
-    if (!visible) {
-      return
-    }
-    store.shell.setMinimalShellMode(false)
-    store.nav.setTitle(navIdx, 'Settings')
-  }, [visible, store, navIdx])
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Settings')
+      store.shell.setMinimalShellMode(false)
+    }, [screen, store]),
+  )
 
   const onPressSwitchAccount = async (acct: AccountData) => {
     track('Settings:SwitchAccountButtonClicked')
     setIsSwitching(true)
     if (await store.session.resumeSession(acct)) {
       setIsSwitching(false)
-      store.nav.tab.fixedTabReset()
+      navigation.navigate('HomeTab')
+      navigation.dispatch(StackActions.popToTop())
       Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
       return
     }
     setIsSwitching(false)
     Toast.show('Sorry! We need you to enter your password.')
-    store.nav.tab.fixedTabReset()
+    navigation.navigate('HomeTab')
+    navigation.dispatch(StackActions.popToTop())
     store.session.clear()
   }
   const onPressAddAccount = () => {
@@ -118,12 +120,7 @@ export const Settings = observer(function Settings({
             noFeedback>
             <View style={[pal.view, styles.linkCard]}>
               <View style={styles.avi}>
-                <UserAvatar
-                  size={40}
-                  displayName={store.me.displayName}
-                  handle={store.me.handle || ''}
-                  avatar={store.me.avatar}
-                />
+                <UserAvatar size={40} avatar={store.me.avatar} />
               </View>
               <View style={[s.flex1]}>
                 <Text type="md-bold" style={pal.text} numberOfLines={1}>
@@ -152,12 +149,7 @@ export const Settings = observer(function Settings({
               isSwitching ? undefined : () => onPressSwitchAccount(account)
             }>
             <View style={styles.avi}>
-              <UserAvatar
-                size={40}
-                displayName={account.displayName}
-                handle={account.handle || ''}
-                avatar={account.aviUrl}
-              />
+              <UserAvatar size={40} avatar={account.aviUrl} />
             </View>
             <View style={[s.flex1]}>
               <Text type="md-bold" style={pal.text}>
diff --git a/src/view/shell/mobile/BottomBar.tsx b/src/view/shell/BottomBar.tsx
index 73c2501ab..18b06968f 100644
--- a/src/view/shell/mobile/BottomBar.tsx
+++ b/src/view/shell/BottomBar.tsx
@@ -6,13 +6,14 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {StackActions, useNavigationState} from '@react-navigation/native'
+import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
 import {Text} from 'view/com/util/text/Text'
 import {useStores} from 'state/index'
 import {useAnalytics} from 'lib/analytics'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation'
 import {clamp} from 'lib/numbers'
 import {
   HomeIcon,
@@ -25,13 +26,24 @@ import {
 } from 'lib/icons'
 import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
+import {getTabState, TabState} from 'lib/routes/helpers'
 
-export const BottomBar = observer(() => {
+export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
   const store = useStores()
   const pal = usePalette('default')
   const minimalShellInterp = useAnimatedValue(0)
   const safeAreaInsets = useSafeAreaInsets()
   const {track} = useAnalytics()
+  const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
+    state => {
+      return {
+        isAtHome: getTabState(state, 'Home') !== TabState.Outside,
+        isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
+        isAtNotifications:
+          getTabState(state, 'Notifications') !== TabState.Outside,
+      }
+    },
+  )
 
   React.useEffect(() => {
     if (store.shell.minimalShellMode) {
@@ -54,62 +66,34 @@ export const BottomBar = observer(() => {
     transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
   }
 
-  const onPressHome = React.useCallback(() => {
-    track('MobileShell:HomeButtonPressed')
-    if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
-      if (!store.nav.tab.canGoBack) {
+  const onPressTab = React.useCallback(
+    (tab: string) => {
+      track(`MobileShell:${tab}ButtonPressed`)
+      const state = navigation.getState()
+      const tabState = getTabState(state, tab)
+      if (tabState === TabState.InsideAtRoot) {
         store.emitScreenSoftReset()
+      } else if (tabState === TabState.Inside) {
+        navigation.dispatch(StackActions.popToTop())
       } else {
-        store.nav.tab.fixedTabReset()
-      }
-    } else {
-      store.nav.switchTo(TabPurpose.Default, false)
-      if (store.nav.tab.index === 0) {
-        store.nav.tab.fixedTabReset()
+        navigation.navigate(`${tab}Tab`)
       }
-    }
-  }, [store, track])
-  const onPressSearch = React.useCallback(() => {
-    track('MobileShell:SearchButtonPressed')
-    if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
-      if (!store.nav.tab.canGoBack) {
-        store.emitScreenSoftReset()
-      } else {
-        store.nav.tab.fixedTabReset()
-      }
-    } else {
-      store.nav.switchTo(TabPurpose.Search, false)
-      if (store.nav.tab.index === 0) {
-        store.nav.tab.fixedTabReset()
-      }
-    }
-  }, [store, track])
-  const onPressNotifications = React.useCallback(() => {
-    track('MobileShell:NotificationsButtonPressed')
-    if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
-      if (!store.nav.tab.canGoBack) {
-        store.emitScreenSoftReset()
-      } else {
-        store.nav.tab.fixedTabReset()
-      }
-    } else {
-      store.nav.switchTo(TabPurpose.Notifs, false)
-      if (store.nav.tab.index === 0) {
-        store.nav.tab.fixedTabReset()
-      }
-    }
-  }, [store, track])
+    },
+    [store, track, navigation],
+  )
+  const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
+  const onPressSearch = React.useCallback(
+    () => onPressTab('Search'),
+    [onPressTab],
+  )
+  const onPressNotifications = React.useCallback(
+    () => onPressTab('Notifications'),
+    [onPressTab],
+  )
   const onPressProfile = React.useCallback(() => {
     track('MobileShell:ProfileButtonPressed')
-    store.nav.navigate(`/profile/${store.me.handle}`)
-  }, [store, track])
-
-  const isAtHome =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
-  const isAtSearch =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
-  const isAtNotifications =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
+    navigation.navigate('Profile', {name: store.me.handle})
+  }, [navigation, track, store.me.handle])
 
   return (
     <Animated.View
diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/Composer.tsx
index 5fca118bd..2ab01c656 100644
--- a/src/view/shell/mobile/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -1,7 +1,7 @@
 import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
-import {ComposePost} from '../../com/composer/ComposePost'
+import {ComposePost} from '../com/composer/Composer'
 import {ComposerOpts} from 'state/models/shell-ui'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -11,7 +11,6 @@ export const Composer = observer(
     active,
     winHeight,
     replyTo,
-    imagesOpen,
     onPost,
     onClose,
     quote,
@@ -19,7 +18,6 @@ export const Composer = observer(
     active: boolean
     winHeight: number
     replyTo?: ComposerOpts['replyTo']
-    imagesOpen?: ComposerOpts['imagesOpen']
     onPost?: ComposerOpts['onPost']
     onClose: () => void
     quote?: ComposerOpts['quote']
@@ -61,7 +59,6 @@ export const Composer = observer(
       <Animated.View style={[styles.wrapper, pal.view, wrapperAnimStyle]}>
         <ComposePost
           replyTo={replyTo}
-          imagesOpen={imagesOpen}
           onPost={onPost}
           onClose={onClose}
           quote={quote}
diff --git a/src/view/shell/web/Composer.tsx b/src/view/shell/Composer.web.tsx
index 0d8484262..465b475fb 100644
--- a/src/view/shell/web/Composer.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {StyleSheet, View} from 'react-native'
-import {ComposePost} from '../../com/composer/ComposePost'
+import {ComposePost} from '../com/composer/Composer'
 import {ComposerOpts} from 'state/models/shell-ui'
 import {usePalette} from 'lib/hooks/usePalette'
 
@@ -9,14 +9,12 @@ export const Composer = observer(
   ({
     active,
     replyTo,
-    imagesOpen,
     onPost,
     onClose,
   }: {
     active: boolean
     winHeight: number
     replyTo?: ComposerOpts['replyTo']
-    imagesOpen?: ComposerOpts['imagesOpen']
     onPost?: ComposerOpts['onPost']
     onClose: () => void
   }) => {
@@ -32,12 +30,7 @@ export const Composer = observer(
     return (
       <View style={styles.mask}>
         <View style={[styles.container, pal.view]}>
-          <ComposePost
-            replyTo={replyTo}
-            imagesOpen={imagesOpen}
-            onPost={onPost}
-            onClose={onClose}
-          />
+          <ComposePost replyTo={replyTo} onPost={onPost} onClose={onClose} />
         </View>
       </View>
     )
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
new file mode 100644
index 000000000..80944e10a
--- /dev/null
+++ b/src/view/shell/Drawer.tsx
@@ -0,0 +1,386 @@
+import React from 'react'
+import {
+  Linking,
+  SafeAreaView,
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {
+  useNavigation,
+  useNavigationState,
+  StackActions,
+} from '@react-navigation/native'
+import {observer} from 'mobx-react-lite'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {s, colors} from 'lib/styles'
+import {FEEDBACK_FORM_URL} from 'lib/constants'
+import {useStores} from 'state/index'
+import {
+  HomeIcon,
+  HomeIconSolid,
+  BellIcon,
+  BellIconSolid,
+  UserIcon,
+  CogIcon,
+  MagnifyingGlassIcon2,
+  MagnifyingGlassIcon2Solid,
+  MoonIcon,
+} from 'lib/icons'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from 'view/com/util/text/Text'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {pluralize} from 'lib/strings/helpers'
+import {getCurrentRoute, isTab, getTabState, TabState} from 'lib/routes/helpers'
+import {NavigationProp} from 'lib/routes/types'
+
+export const DrawerContent = observer(() => {
+  const theme = useTheme()
+  const pal = usePalette('default')
+  const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
+  const {track} = useAnalytics()
+  const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
+    state => {
+      const currentRoute = state ? getCurrentRoute(state) : false
+      return {
+        isAtHome: currentRoute ? isTab(currentRoute.name, 'Home') : true,
+        isAtSearch: currentRoute ? isTab(currentRoute.name, 'Search') : false,
+        isAtNotifications: currentRoute
+          ? isTab(currentRoute.name, 'Notifications')
+          : false,
+      }
+    },
+  )
+
+  // events
+  // =
+
+  const onPressTab = React.useCallback(
+    (tab: string) => {
+      track('Menu:ItemClicked', {url: tab})
+      const state = navigation.getState()
+      store.shell.closeDrawer()
+      const tabState = getTabState(state, tab)
+      if (tabState === TabState.InsideAtRoot) {
+        store.emitScreenSoftReset()
+      } else if (tabState === TabState.Inside) {
+        navigation.dispatch(StackActions.popToTop())
+      } else {
+        // @ts-ignore must be Home, Search, or Notifications
+        navigation.navigate(`${tab}Tab`)
+      }
+    },
+    [store, track, navigation],
+  )
+
+  const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
+
+  const onPressSearch = React.useCallback(
+    () => onPressTab('Search'),
+    [onPressTab],
+  )
+
+  const onPressNotifications = React.useCallback(
+    () => onPressTab('Notifications'),
+    [onPressTab],
+  )
+
+  const onPressProfile = React.useCallback(() => {
+    track('Menu:ItemClicked', {url: 'Profile'})
+    navigation.navigate('Profile', {name: store.me.handle})
+    store.shell.closeDrawer()
+  }, [navigation, track, store.me.handle, store.shell])
+
+  const onPressSettings = React.useCallback(() => {
+    track('Menu:ItemClicked', {url: 'Settings'})
+    navigation.navigate('Settings')
+    store.shell.closeDrawer()
+  }, [navigation, track, store.shell])
+
+  const onPressFeedback = () => {
+    track('Menu:FeedbackClicked')
+    Linking.openURL(FEEDBACK_FORM_URL)
+  }
+
+  // rendering
+  // =
+
+  const MenuItem = ({
+    icon,
+    label,
+    count,
+    bold,
+    onPress,
+  }: {
+    icon: JSX.Element
+    label: string
+    count?: number
+    bold?: boolean
+    onPress: () => void
+  }) => (
+    <TouchableOpacity
+      testID={`menuItemButton-${label}`}
+      style={styles.menuItem}
+      onPress={onPress}>
+      <View style={[styles.menuItemIconWrapper]}>
+        {icon}
+        {count ? (
+          <View style={styles.menuItemCount}>
+            <Text style={styles.menuItemCountLabel}>{count}</Text>
+          </View>
+        ) : undefined}
+      </View>
+      <Text
+        type={bold ? '2xl-bold' : '2xl'}
+        style={[pal.text, s.flex1]}
+        numberOfLines={1}>
+        {label}
+      </Text>
+    </TouchableOpacity>
+  )
+
+  const onDarkmodePress = () => {
+    track('Menu:ItemClicked', {url: '/darkmode'})
+    store.shell.setDarkMode(!store.shell.darkMode)
+  }
+
+  return (
+    <View
+      testID="menuView"
+      style={[
+        styles.view,
+        theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
+      ]}>
+      <SafeAreaView style={s.flex1}>
+        <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
+          <UserAvatar size={80} avatar={store.me.avatar} />
+          <Text
+            type="title-lg"
+            style={[pal.text, s.bold, styles.profileCardDisplayName]}>
+            {store.me.displayName || store.me.handle}
+          </Text>
+          <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
+            @{store.me.handle}
+          </Text>
+          <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
+            <Text type="xl-medium" style={pal.text}>
+              {store.me.followersCount || 0}
+            </Text>{' '}
+            {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
+            <Text type="xl-medium" style={pal.text}>
+              {store.me.followsCount || 0}
+            </Text>{' '}
+            following
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        <View>
+          <MenuItem
+            icon={
+              isAtSearch ? (
+                <MagnifyingGlassIcon2Solid
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size={24}
+                  strokeWidth={1.7}
+                />
+              ) : (
+                <MagnifyingGlassIcon2
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size={24}
+                  strokeWidth={1.7}
+                />
+              )
+            }
+            label="Search"
+            bold={isAtSearch}
+            onPress={onPressSearch}
+          />
+          <MenuItem
+            icon={
+              isAtHome ? (
+                <HomeIconSolid
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size="24"
+                  strokeWidth={3.25}
+                />
+              ) : (
+                <HomeIcon
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size="24"
+                  strokeWidth={3.25}
+                />
+              )
+            }
+            label="Home"
+            bold={isAtHome}
+            onPress={onPressHome}
+          />
+          <MenuItem
+            icon={
+              isAtNotifications ? (
+                <BellIconSolid
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size="24"
+                  strokeWidth={1.7}
+                />
+              ) : (
+                <BellIcon
+                  style={pal.text as StyleProp<ViewStyle>}
+                  size="24"
+                  strokeWidth={1.7}
+                />
+              )
+            }
+            label="Notifications"
+            count={store.me.notifications.unreadCount}
+            bold={isAtNotifications}
+            onPress={onPressNotifications}
+          />
+          <MenuItem
+            icon={
+              <UserIcon
+                style={pal.text as StyleProp<ViewStyle>}
+                size="26"
+                strokeWidth={1.5}
+              />
+            }
+            label="Profile"
+            onPress={onPressProfile}
+          />
+          <MenuItem
+            icon={
+              <CogIcon
+                style={pal.text as StyleProp<ViewStyle>}
+                size="26"
+                strokeWidth={1.75}
+              />
+            }
+            label="Settings"
+            onPress={onPressSettings}
+          />
+        </View>
+        <View style={s.flex1} />
+        <View style={styles.footer}>
+          <TouchableOpacity
+            onPress={onDarkmodePress}
+            style={[
+              styles.footerBtn,
+              theme.colorScheme === 'light'
+                ? pal.btn
+                : styles.footerBtnDarkMode,
+            ]}>
+            <MoonIcon
+              size={22}
+              style={pal.text as StyleProp<ViewStyle>}
+              strokeWidth={2}
+            />
+          </TouchableOpacity>
+          <TouchableOpacity
+            onPress={onPressFeedback}
+            style={[
+              styles.footerBtn,
+              styles.footerBtnFeedback,
+              theme.colorScheme === 'light'
+                ? styles.footerBtnFeedbackLight
+                : styles.footerBtnFeedbackDark,
+            ]}>
+            <FontAwesomeIcon
+              style={pal.link as FontAwesomeIconStyle}
+              size={19}
+              icon={['far', 'message']}
+            />
+            <Text type="2xl-medium" style={[pal.link, s.pl10]}>
+              Feedback
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </SafeAreaView>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  view: {
+    flex: 1,
+    paddingTop: 20,
+    paddingBottom: 50,
+    paddingLeft: 20,
+  },
+  viewDarkMode: {
+    backgroundColor: '#1B1919',
+  },
+
+  profileCardDisplayName: {
+    marginTop: 20,
+    paddingRight: 30,
+  },
+  profileCardHandle: {
+    marginTop: 4,
+    paddingRight: 30,
+  },
+  profileCardFollowers: {
+    marginTop: 16,
+    paddingRight: 30,
+  },
+
+  menuItem: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 16,
+    paddingRight: 10,
+  },
+  menuItemIconWrapper: {
+    width: 24,
+    height: 24,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 12,
+  },
+  menuItemCount: {
+    position: 'absolute',
+    right: -6,
+    top: -2,
+    backgroundColor: colors.red3,
+    paddingHorizontal: 4,
+    paddingBottom: 1,
+    borderRadius: 6,
+  },
+  menuItemCountLabel: {
+    fontSize: 12,
+    fontWeight: 'bold',
+    color: colors.white,
+  },
+
+  footer: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    paddingRight: 30,
+    paddingTop: 80,
+  },
+  footerBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    padding: 10,
+    borderRadius: 25,
+  },
+  footerBtnDarkMode: {
+    backgroundColor: colors.black,
+  },
+  footerBtnFeedback: {
+    paddingHorizontal: 24,
+  },
+  footerBtnFeedbackLight: {
+    backgroundColor: '#DDEFFF',
+  },
+  footerBtnFeedbackDark: {
+    backgroundColor: colors.blue6,
+  },
+})
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
new file mode 100644
index 000000000..46c77178b
--- /dev/null
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -0,0 +1,254 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {useNavigation, useNavigationState} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from 'view/com/util/text/Text'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Link} from 'view/com/util/Link'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {
+  HomeIcon,
+  HomeIconSolid,
+  MagnifyingGlassIcon2,
+  MagnifyingGlassIcon2Solid,
+  BellIcon,
+  BellIconSolid,
+  UserIcon,
+  UserIconSolid,
+  CogIcon,
+  CogIconSolid,
+  ComposeIcon2,
+} from 'lib/icons'
+import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '../../../routes'
+
+const ProfileCard = observer(() => {
+  const store = useStores()
+  return (
+    <Link href={`/profile/${store.me.handle}`} style={styles.profileCard}>
+      <UserAvatar avatar={store.me.avatar} size={64} />
+    </Link>
+  )
+})
+
+function BackBtn() {
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+  const shouldShow = useNavigationState(state => !isStateAtTabRoot(state))
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (!shouldShow) {
+    return <></>
+  }
+  return (
+    <TouchableOpacity
+      testID="viewHeaderBackOrMenuBtn"
+      onPress={onPressBack}
+      style={styles.backBtn}>
+      <FontAwesomeIcon
+        size={24}
+        icon="angle-left"
+        style={pal.text as FontAwesomeIconStyle}
+      />
+    </TouchableOpacity>
+  )
+}
+
+interface NavItemProps {
+  count?: number
+  href: string
+  icon: JSX.Element
+  iconFilled: JSX.Element
+  label: string
+}
+const NavItem = observer(
+  ({count, href, icon, iconFilled, label}: NavItemProps) => {
+    const pal = usePalette('default')
+    const [pathName] = React.useMemo(() => router.matchPath(href), [href])
+    const currentRouteName = useNavigationState(state => {
+      if (!state) {
+        return 'Home'
+      }
+      return getCurrentRoute(state).name
+    })
+    const isCurrent = isTab(currentRouteName, pathName)
+
+    return (
+      <Link href={href} style={styles.navItem}>
+        <View style={[styles.navItemIconWrapper]}>
+          {isCurrent ? iconFilled : icon}
+          {typeof count === 'number' && count > 0 && (
+            <Text type="button" style={styles.navItemCount}>
+              {count}
+            </Text>
+          )}
+        </View>
+        <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
+          {label}
+        </Text>
+      </Link>
+    )
+  },
+)
+
+function ComposeBtn() {
+  const store = useStores()
+  const onPressCompose = () => store.shell.openComposer({})
+
+  return (
+    <TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}>
+      <View style={styles.newPostBtnIconWrapper}>
+        <ComposeIcon2
+          size={19}
+          strokeWidth={2}
+          style={styles.newPostBtnLabel}
+        />
+      </View>
+      <Text type="button" style={styles.newPostBtnLabel}>
+        New Post
+      </Text>
+    </TouchableOpacity>
+  )
+}
+
+export const DesktopLeftNav = observer(function DesktopLeftNav() {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  return (
+    <View style={styles.leftNav}>
+      <ProfileCard />
+      <BackBtn />
+      <NavItem
+        href="/"
+        icon={<HomeIcon size={24} style={pal.text} />}
+        iconFilled={
+          <HomeIconSolid strokeWidth={4} size={24} style={pal.text} />
+        }
+        label="Home"
+      />
+      <NavItem
+        href="/search"
+        icon={
+          <MagnifyingGlassIcon2 strokeWidth={2} size={24} style={pal.text} />
+        }
+        iconFilled={
+          <MagnifyingGlassIcon2Solid
+            strokeWidth={2}
+            size={24}
+            style={pal.text}
+          />
+        }
+        label="Search"
+      />
+      <NavItem
+        href="/notifications"
+        count={store.me.notifications.unreadCount}
+        icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
+        iconFilled={
+          <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
+        }
+        label="Notifications"
+      />
+      <NavItem
+        href={`/profile/${store.me.handle}`}
+        icon={<UserIcon strokeWidth={1.75} size={28} style={pal.text} />}
+        iconFilled={
+          <UserIconSolid strokeWidth={1.75} size={28} style={pal.text} />
+        }
+        label="Profile"
+      />
+      <NavItem
+        href="/settings"
+        icon={<CogIcon strokeWidth={1.75} size={28} style={pal.text} />}
+        iconFilled={
+          <CogIconSolid strokeWidth={1.5} size={28} style={pal.text} />
+        }
+        label="Settings"
+      />
+      <ComposeBtn />
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  leftNav: {
+    position: 'absolute',
+    top: 10,
+    right: 'calc(50vw + 300px)',
+    width: 220,
+  },
+
+  profileCard: {
+    marginVertical: 10,
+    width: 60,
+  },
+
+  backBtn: {
+    position: 'absolute',
+    top: 12,
+    right: 12,
+    width: 30,
+    height: 30,
+  },
+
+  navItem: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingTop: 14,
+    paddingBottom: 10,
+  },
+  navItemIconWrapper: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: 28,
+    height: 28,
+    marginRight: 10,
+    marginTop: 2,
+  },
+  navItemCount: {
+    position: 'absolute',
+    top: 0,
+    left: 15,
+    backgroundColor: colors.blue3,
+    color: colors.white,
+    fontSize: 12,
+    fontWeight: 'bold',
+    paddingHorizontal: 4,
+    borderRadius: 6,
+  },
+
+  newPostBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: 136,
+    borderRadius: 24,
+    paddingVertical: 10,
+    paddingHorizontal: 16,
+    backgroundColor: colors.blue3,
+    marginTop: 20,
+  },
+  newPostBtnIconWrapper: {
+    marginRight: 8,
+  },
+  newPostBtnLabel: {
+    color: colors.white,
+    fontSize: 16,
+    fontWeight: 'bold',
+  },
+})
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
new file mode 100644
index 000000000..a196951af
--- /dev/null
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {StyleSheet, View} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {DesktopSearch} from './Search'
+import {Text} from 'view/com/util/text/Text'
+import {TextLink} from 'view/com/util/Link'
+import {FEEDBACK_FORM_URL} from 'lib/constants'
+
+export const DesktopRightNav = observer(function DesktopRightNav() {
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.rightNav, pal.view]}>
+      <DesktopSearch />
+      <View style={styles.message}>
+        <Text type="md" style={[pal.textLight, styles.messageLine]}>
+          Welcome to Bluesky! This is a beta application that's still in
+          development.
+        </Text>
+        <TextLink
+          type="md"
+          style={pal.link}
+          href={FEEDBACK_FORM_URL}
+          text="Send feedback"
+        />
+      </View>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  rightNav: {
+    position: 'absolute',
+    top: 20,
+    left: 'calc(50vw + 330px)',
+    width: 300,
+  },
+
+  message: {
+    marginTop: 20,
+    paddingHorizontal: 10,
+  },
+  messageLine: {
+    marginBottom: 10,
+  },
+})
diff --git a/src/view/shell/web/DesktopSearch.tsx b/src/view/shell/desktop/Search.tsx
index 43f13ca2b..7c96dbac2 100644
--- a/src/view/shell/web/DesktopSearch.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -1,11 +1,12 @@
 import React from 'react'
-import {TextInput, View, StyleSheet, TouchableOpacity, Text} from 'react-native'
+import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
 import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {MagnifyingGlassIcon} from 'lib/icons'
-import {ProfileCard} from '../../com/profile/ProfileCard'
+import {MagnifyingGlassIcon2} from 'lib/icons'
+import {ProfileCard} from 'view/com/profile/ProfileCard'
+import {Text} from 'view/com/util/text/Text'
 
 export const DesktopSearch = observer(function DesktopSearch() {
   const store = useStores()
@@ -35,9 +36,10 @@ export const DesktopSearch = observer(function DesktopSearch() {
 
   return (
     <View style={styles.container}>
-      <View style={[pal.borderDark, pal.view, styles.search]}>
+      <View
+        style={[{backgroundColor: pal.colors.backgroundLight}, styles.search]}>
         <View style={[styles.inputContainer]}>
-          <MagnifyingGlassIcon
+          <MagnifyingGlassIcon2
             size={18}
             style={[pal.textLight, styles.iconWrapper]}
           />
@@ -57,7 +59,9 @@ export const DesktopSearch = observer(function DesktopSearch() {
           {query ? (
             <View style={styles.cancelBtn}>
               <TouchableOpacity onPress={onPressCancelSearch}>
-                <Text style={[pal.link]}>Cancel</Text>
+                <Text type="lg" style={[pal.link]}>
+                  Cancel
+                </Text>
               </TouchableOpacity>
             </View>
           ) : undefined}
@@ -97,21 +101,23 @@ const styles = StyleSheet.create({
     width: 300,
   },
   search: {
-    paddingHorizontal: 10,
+    paddingHorizontal: 16,
+    paddingVertical: 2,
     width: 300,
     borderRadius: 20,
-    borderWidth: 1,
   },
   inputContainer: {
     flexDirection: 'row',
   },
   iconWrapper: {
+    position: 'relative',
+    top: 2,
     paddingVertical: 7,
-    marginRight: 4,
+    marginRight: 8,
   },
   input: {
     flex: 1,
-    fontSize: 16,
+    fontSize: 18,
     width: '100%',
     paddingTop: 7,
     paddingBottom: 7,
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
new file mode 100644
index 000000000..116915ff4
--- /dev/null
+++ b/src/view/shell/index.tsx
@@ -0,0 +1,139 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {StatusBar, StyleSheet, useWindowDimensions, View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {Drawer} from 'react-native-drawer-layout'
+import {useNavigationState} from '@react-navigation/native'
+import {useStores} from 'state/index'
+import {Login} from 'view/screens/Login'
+import {ModalsContainer} from 'view/com/modals/Modal'
+import {Lightbox} from 'view/com/lightbox/Lightbox'
+import {Text} from 'view/com/util/text/Text'
+import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
+import {DrawerContent} from './Drawer'
+import {Composer} from './Composer'
+import {s} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+import {RoutesContainer, TabsNavigator} from '../../Navigation'
+import {isStateAtTabRoot} from 'lib/routes/helpers'
+
+const ShellInner = observer(() => {
+  const store = useStores()
+  const winDim = useWindowDimensions()
+  const safeAreaInsets = useSafeAreaInsets()
+  const containerPadding = React.useMemo(
+    () => ({height: '100%', paddingTop: safeAreaInsets.top}),
+    [safeAreaInsets],
+  )
+  const renderDrawerContent = React.useCallback(() => <DrawerContent />, [])
+  const onOpenDrawer = React.useCallback(
+    () => store.shell.openDrawer(),
+    [store],
+  )
+  const onCloseDrawer = React.useCallback(
+    () => store.shell.closeDrawer(),
+    [store],
+  )
+  const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
+
+  return (
+    <>
+      <View style={containerPadding}>
+        <ErrorBoundary>
+          <Drawer
+            renderDrawerContent={renderDrawerContent}
+            open={store.shell.isDrawerOpen}
+            onOpen={onOpenDrawer}
+            onClose={onCloseDrawer}
+            swipeEdgeWidth={winDim.width}
+            swipeEnabled={!canGoBack}>
+            <TabsNavigator />
+          </Drawer>
+        </ErrorBoundary>
+      </View>
+      <ModalsContainer />
+      <Lightbox />
+      <Composer
+        active={store.shell.isComposerActive}
+        onClose={() => store.shell.closeComposer()}
+        winHeight={winDim.height}
+        replyTo={store.shell.composerOpts?.replyTo}
+        onPost={store.shell.composerOpts?.onPost}
+        quote={store.shell.composerOpts?.quote}
+      />
+    </>
+  )
+})
+
+export const Shell: React.FC = observer(() => {
+  const theme = useTheme()
+  const pal = usePalette('default')
+  const store = useStores()
+
+  if (store.hackUpgradeNeeded) {
+    return (
+      <View style={styles.outerContainer}>
+        <View style={[s.flexCol, s.p20, s.h100pct]}>
+          <View style={s.flex1} />
+          <View>
+            <Text type="title-2xl" style={s.pb10}>
+              Update required
+            </Text>
+            <Text style={[s.pb20, s.bold]}>
+              Please update your app to the latest version. If no update is
+              available yet, please check the App Store in a day or so.
+            </Text>
+            <Text type="title" style={s.pb10}>
+              What's happening?
+            </Text>
+            <Text style={s.pb10}>
+              We're in the final stages of the AT Protocol's v1 development. To
+              make sure everything works as well as possible, we're making final
+              breaking changes to the APIs.
+            </Text>
+            <Text>
+              If we didn't botch this process, a new version of the app should
+              be available now.
+            </Text>
+          </View>
+          <View style={s.flex1} />
+          <View style={s.footerSpacer} />
+        </View>
+      </View>
+    )
+  }
+
+  if (!store.session.hasSession) {
+    return (
+      <View style={styles.outerContainer}>
+        <StatusBar
+          barStyle={
+            theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
+          }
+        />
+        <Login />
+        <ModalsContainer />
+      </View>
+    )
+  }
+
+  return (
+    <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
+      <StatusBar
+        barStyle={
+          theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
+        }
+      />
+      <RoutesContainer>
+        <ShellInner />
+      </RoutesContainer>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  outerContainer: {
+    height: '100%',
+  },
+})
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
new file mode 100644
index 000000000..9a97505e8
--- /dev/null
+++ b/src/view/shell/index.web.tsx
@@ -0,0 +1,113 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {View, StyleSheet} from 'react-native'
+import {useStores} from 'state/index'
+import {DesktopLeftNav} from './desktop/LeftNav'
+import {DesktopRightNav} from './desktop/RightNav'
+import {Login} from '../screens/Login'
+import {ErrorBoundary} from '../com/util/ErrorBoundary'
+import {Lightbox} from '../com/lightbox/Lightbox'
+import {ModalsContainer} from '../com/modals/Modal'
+import {Text} from 'view/com/util/text/Text'
+import {Composer} from './Composer.web'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+import {s, colors} from 'lib/styles'
+import {isMobileWeb} from 'platform/detection'
+import {RoutesContainer, FlatNavigator} from '../../Navigation'
+
+const ShellInner = observer(() => {
+  const store = useStores()
+
+  return (
+    <>
+      <View style={s.hContentRegion}>
+        <ErrorBoundary>
+          <FlatNavigator />
+        </ErrorBoundary>
+      </View>
+      <DesktopLeftNav />
+      <DesktopRightNav />
+      <View style={[styles.viewBorder, styles.viewBorderLeft]} />
+      <View style={[styles.viewBorder, styles.viewBorderRight]} />
+      <Composer
+        active={store.shell.isComposerActive}
+        onClose={() => store.shell.closeComposer()}
+        winHeight={0}
+        replyTo={store.shell.composerOpts?.replyTo}
+        onPost={store.shell.composerOpts?.onPost}
+      />
+      <ModalsContainer />
+      <Lightbox />
+    </>
+  )
+})
+
+export const Shell: React.FC = observer(() => {
+  const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+  const store = useStores()
+
+  if (isMobileWeb) {
+    return <NoMobileWeb />
+  }
+
+  if (!store.session.hasSession) {
+    return (
+      <View style={[s.hContentRegion, pageBg]}>
+        <Login />
+        <ModalsContainer />
+      </View>
+    )
+  }
+
+  return (
+    <View style={[s.hContentRegion, pageBg]}>
+      <RoutesContainer>
+        <ShellInner />
+      </RoutesContainer>
+    </View>
+  )
+})
+
+function NoMobileWeb() {
+  const pal = usePalette('default')
+  return (
+    <View style={[pal.view, styles.noMobileWeb]}>
+      <Text type="title-2xl" style={s.pb20}>
+        We're so sorry!
+      </Text>
+      <Text type="lg">
+        This app is not available for mobile Web yet. Please open it on your
+        desktop or download the iOS app.
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  bgLight: {
+    backgroundColor: colors.white,
+  },
+  bgDark: {
+    backgroundColor: colors.black, // TODO
+  },
+  viewBorder: {
+    position: 'absolute',
+    width: 1,
+    height: '100%',
+    borderLeftWidth: 1,
+    borderLeftColor: colors.gray2,
+  },
+  viewBorderLeft: {
+    left: 'calc(50vw - 300px)',
+  },
+  viewBorderRight: {
+    left: 'calc(50vw + 300px)',
+  },
+  noMobileWeb: {
+    height: '100%',
+    justifyContent: 'center',
+    paddingHorizontal: 20,
+    paddingBottom: 40,
+  },
+})
diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx
deleted file mode 100644
index 927e712e1..000000000
--- a/src/view/shell/mobile/Menu.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import React from 'react'
-import {
-  Linking,
-  StyleProp,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {s, colors} from 'lib/styles'
-import {FEEDBACK_FORM_URL} from 'lib/constants'
-import {useStores} from 'state/index'
-import {
-  HomeIcon,
-  HomeIconSolid,
-  BellIcon,
-  BellIconSolid,
-  UserIcon,
-  CogIcon,
-  MagnifyingGlassIcon2,
-  MagnifyingGlassIcon2Solid,
-  MoonIcon,
-} from 'lib/icons'
-import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation'
-import {UserAvatar} from '../../com/util/UserAvatar'
-import {Text} from '../../com/util/text/Text'
-import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics'
-import {pluralize} from 'lib/strings/helpers'
-
-export const Menu = observer(({onClose}: {onClose: () => void}) => {
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const store = useStores()
-  const {track} = useAnalytics()
-
-  // events
-  // =
-
-  const onNavigate = (url: string) => {
-    track('Menu:ItemClicked', {url})
-
-    onClose()
-    if (url === TabPurposeMainPath[TabPurpose.Notifs]) {
-      store.nav.switchTo(TabPurpose.Notifs, true)
-    } else if (url === TabPurposeMainPath[TabPurpose.Search]) {
-      store.nav.switchTo(TabPurpose.Search, true)
-    } else {
-      store.nav.switchTo(TabPurpose.Default, true)
-      if (url !== '/') {
-        store.nav.navigate(url)
-      }
-    }
-  }
-
-  const onPressFeedback = () => {
-    track('Menu:FeedbackClicked')
-    Linking.openURL(FEEDBACK_FORM_URL)
-  }
-
-  // rendering
-  // =
-
-  const MenuItem = ({
-    icon,
-    label,
-    count,
-    url,
-    bold,
-    onPress,
-  }: {
-    icon: JSX.Element
-    label: string
-    count?: number
-    url?: string
-    bold?: boolean
-    onPress?: () => void
-  }) => (
-    <TouchableOpacity
-      testID={`menuItemButton-${label}`}
-      style={styles.menuItem}
-      onPress={onPress ? onPress : () => onNavigate(url || '/')}>
-      <View style={[styles.menuItemIconWrapper]}>
-        {icon}
-        {count ? (
-          <View style={styles.menuItemCount}>
-            <Text style={styles.menuItemCountLabel}>{count}</Text>
-          </View>
-        ) : undefined}
-      </View>
-      <Text
-        type={bold ? '2xl-bold' : '2xl'}
-        style={[pal.text, s.flex1]}
-        numberOfLines={1}>
-        {label}
-      </Text>
-    </TouchableOpacity>
-  )
-
-  const onDarkmodePress = () => {
-    track('Menu:ItemClicked', {url: '/darkmode'})
-    store.shell.setDarkMode(!store.shell.darkMode)
-  }
-
-  const isAtHome =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
-  const isAtSearch =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
-  const isAtNotifications =
-    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
-
-  return (
-    <View
-      testID="menuView"
-      style={[
-        styles.view,
-        theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
-      ]}>
-      <TouchableOpacity
-        testID="profileCardButton"
-        onPress={() => onNavigate(`/profile/${store.me.handle}`)}>
-        <UserAvatar
-          size={80}
-          displayName={store.me.displayName}
-          handle={store.me.handle}
-          avatar={store.me.avatar}
-        />
-        <Text
-          type="title-lg"
-          style={[pal.text, s.bold, styles.profileCardDisplayName]}>
-          {store.me.displayName || store.me.handle}
-        </Text>
-        <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
-          @{store.me.handle}
-        </Text>
-        <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
-          <Text type="xl-medium" style={pal.text}>
-            {store.me.followersCount || 0}
-          </Text>{' '}
-          {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
-          <Text type="xl-medium" style={pal.text}>
-            {store.me.followsCount || 0}
-          </Text>{' '}
-          following
-        </Text>
-      </TouchableOpacity>
-      <View style={s.flex1} />
-      <View>
-        <MenuItem
-          icon={
-            isAtSearch ? (
-              <MagnifyingGlassIcon2Solid
-                style={pal.text as StyleProp<ViewStyle>}
-                size={24}
-                strokeWidth={1.7}
-              />
-            ) : (
-              <MagnifyingGlassIcon2
-                style={pal.text as StyleProp<ViewStyle>}
-                size={24}
-                strokeWidth={1.7}
-              />
-            )
-          }
-          label="Search"
-          url="/search"
-          bold={isAtSearch}
-        />
-        <MenuItem
-          icon={
-            isAtHome ? (
-              <HomeIconSolid
-                style={pal.text as StyleProp<ViewStyle>}
-                size="24"
-                strokeWidth={3.25}
-                fillOpacity={1}
-              />
-            ) : (
-              <HomeIcon
-                style={pal.text as StyleProp<ViewStyle>}
-                size="24"
-                strokeWidth={3.25}
-              />
-            )
-          }
-          label="Home"
-          url="/"
-          bold={isAtHome}
-        />
-        <MenuItem
-          icon={
-            isAtNotifications ? (
-              <BellIconSolid
-                style={pal.text as StyleProp<ViewStyle>}
-                size="24"
-                strokeWidth={1.7}
-                fillOpacity={1}
-              />
-            ) : (
-              <BellIcon
-                style={pal.text as StyleProp<ViewStyle>}
-                size="24"
-                strokeWidth={1.7}
-              />
-            )
-          }
-          label="Notifications"
-          url="/notifications"
-          count={store.me.notifications.unreadCount}
-          bold={isAtNotifications}
-        />
-        <MenuItem
-          icon={
-            <UserIcon
-              style={pal.text as StyleProp<ViewStyle>}
-              size="26"
-              strokeWidth={1.5}
-            />
-          }
-          label="Profile"
-          url={`/profile/${store.me.handle}`}
-        />
-        <MenuItem
-          icon={
-            <CogIcon
-              style={pal.text as StyleProp<ViewStyle>}
-              size="26"
-              strokeWidth={1.75}
-            />
-          }
-          label="Settings"
-          url="/settings"
-        />
-      </View>
-      <View style={s.flex1} />
-      <View style={styles.footer}>
-        <TouchableOpacity
-          onPress={onDarkmodePress}
-          style={[
-            styles.footerBtn,
-            theme.colorScheme === 'light' ? pal.btn : styles.footerBtnDarkMode,
-          ]}>
-          <MoonIcon
-            size={22}
-            style={pal.text as StyleProp<ViewStyle>}
-            strokeWidth={2}
-          />
-        </TouchableOpacity>
-        <TouchableOpacity
-          onPress={onPressFeedback}
-          style={[
-            styles.footerBtn,
-            styles.footerBtnFeedback,
-            theme.colorScheme === 'light'
-              ? styles.footerBtnFeedbackLight
-              : styles.footerBtnFeedbackDark,
-          ]}>
-          <FontAwesomeIcon
-            style={pal.link as FontAwesomeIconStyle}
-            size={19}
-            icon={['far', 'message']}
-          />
-          <Text type="2xl-medium" style={[pal.link, s.pl10]}>
-            Feedback
-          </Text>
-        </TouchableOpacity>
-      </View>
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  view: {
-    flex: 1,
-    paddingTop: 20,
-    paddingBottom: 50,
-    paddingLeft: 30,
-  },
-  viewDarkMode: {
-    backgroundColor: '#1B1919',
-  },
-
-  profileCardDisplayName: {
-    marginTop: 20,
-    paddingRight: 20,
-  },
-  profileCardHandle: {
-    marginTop: 4,
-    paddingRight: 20,
-  },
-  profileCardFollowers: {
-    marginTop: 16,
-    paddingRight: 20,
-  },
-
-  menuItem: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 16,
-    paddingRight: 10,
-  },
-  menuItemIconWrapper: {
-    width: 24,
-    height: 24,
-    alignItems: 'center',
-    justifyContent: 'center',
-    marginRight: 12,
-  },
-  menuItemCount: {
-    position: 'absolute',
-    right: -6,
-    top: -2,
-    backgroundColor: colors.red3,
-    paddingHorizontal: 4,
-    paddingBottom: 1,
-    borderRadius: 6,
-  },
-  menuItemCountLabel: {
-    fontSize: 12,
-    fontWeight: 'bold',
-    color: colors.white,
-  },
-
-  footer: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    paddingRight: 30,
-    paddingTop: 80,
-  },
-  footerBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    padding: 10,
-    borderRadius: 25,
-  },
-  footerBtnDarkMode: {
-    backgroundColor: colors.black,
-  },
-  footerBtnFeedback: {
-    paddingHorizontal: 24,
-  },
-  footerBtnFeedbackLight: {
-    backgroundColor: '#DDEFFF',
-  },
-  footerBtnFeedbackDark: {
-    backgroundColor: colors.blue6,
-  },
-})
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
deleted file mode 100644
index 01df6c165..000000000
--- a/src/view/shell/mobile/index.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-import React, {useState} from 'react'
-import {observer} from 'mobx-react-lite'
-import {
-  Animated,
-  StatusBar,
-  StyleSheet,
-  TouchableWithoutFeedback,
-  useWindowDimensions,
-  View,
-} from 'react-native'
-import {ScreenContainer, Screen} from 'react-native-screens'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
-import {useStores} from 'state/index'
-import {NavigationModel} from 'state/models/navigation'
-import {match, MatchResult} from '../../routes'
-import {Login} from '../../screens/Login'
-import {Menu} from './Menu'
-import {BottomBar} from './BottomBar'
-import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
-import {ModalsContainer} from '../../com/modals/Modal'
-import {Lightbox} from '../../com/lightbox/Lightbox'
-import {Text} from '../../com/util/text/Text'
-import {ErrorBoundary} from '../../com/util/ErrorBoundary'
-import {Composer} from './Composer'
-import {s, colors} from 'lib/styles'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
-
-export const MobileShell: React.FC = observer(() => {
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const store = useStores()
-  const winDim = useWindowDimensions()
-  const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
-  const swipeGestureInterp = useAnimatedValue(0)
-  const safeAreaInsets = useSafeAreaInsets()
-  const screenRenderDesc = constructScreenRenderDesc(store.nav)
-
-  // navigation swipes
-  // =
-  const isMenuActive = store.shell.isMainMenuOpen
-  const canSwipeLeft = store.nav.tab.canGoBack || !isMenuActive
-  const canSwipeRight = isMenuActive
-  const onNavSwipeStartDirection = (dx: number) => {
-    if (dx < 0 && !store.nav.tab.canGoBack) {
-      setMenuSwipingDirection(dx)
-    } else if (dx > 0 && isMenuActive) {
-      setMenuSwipingDirection(dx)
-    } else {
-      setMenuSwipingDirection(0)
-    }
-  }
-  const onNavSwipeEnd = (dx: number) => {
-    if (dx < 0) {
-      if (store.nav.tab.canGoBack) {
-        store.nav.tab.goBack()
-      } else {
-        store.shell.setMainMenuOpen(true)
-      }
-    } else if (dx > 0) {
-      if (isMenuActive) {
-        store.shell.setMainMenuOpen(false)
-      }
-    }
-    setMenuSwipingDirection(0)
-  }
-  const swipeTranslateX = Animated.multiply(
-    swipeGestureInterp,
-    winDim.width * -1,
-  )
-  const swipeTransform = store.nav.tab.canGoBack
-    ? {transform: [{translateX: swipeTranslateX}]}
-    : undefined
-  let shouldRenderMenu = false
-  let menuTranslateX
-  const menuDrawerWidth = winDim.width - 100
-  if (isMenuActive) {
-    // menu is active, interpret swipes as closes
-    menuTranslateX = Animated.multiply(swipeGestureInterp, menuDrawerWidth * -1)
-    shouldRenderMenu = true
-  } else if (!store.nav.tab.canGoBack) {
-    // at back of history, interpret swipes as opens
-    menuTranslateX = Animated.subtract(
-      menuDrawerWidth * -1,
-      Animated.multiply(swipeGestureInterp, menuDrawerWidth),
-    )
-    shouldRenderMenu = true
-  }
-  const menuSwipeTransform = menuTranslateX
-    ? {
-        transform: [{translateX: menuTranslateX}],
-      }
-    : undefined
-  const swipeOpacity = {
-    opacity: swipeGestureInterp.interpolate({
-      inputRange: [-1, 0, 1],
-      outputRange: [0, 0.6, 0],
-    }),
-  }
-  const menuSwipeOpacity =
-    menuSwipingDirection !== 0
-      ? {
-          opacity: swipeGestureInterp.interpolate({
-            inputRange: menuSwipingDirection > 0 ? [0, 1] : [-1, 0],
-            outputRange: [0.6, 0],
-          }),
-        }
-      : undefined
-
-  if (store.hackUpgradeNeeded) {
-    return (
-      <View style={styles.outerContainer}>
-        <View style={[s.flexCol, s.p20, s.h100pct]}>
-          <View style={s.flex1} />
-          <View>
-            <Text type="title-2xl" style={s.pb10}>
-              Update required
-            </Text>
-            <Text style={[s.pb20, s.bold]}>
-              Please update your app to the latest version. If no update is
-              available yet, please check the App Store in a day or so.
-            </Text>
-            <Text type="title" style={s.pb10}>
-              What's happening?
-            </Text>
-            <Text style={s.pb10}>
-              We're in the final stages of the AT Protocol's v1 development. To
-              make sure everything works as well as possible, we're making final
-              breaking changes to the APIs.
-            </Text>
-            <Text>
-              If we didn't botch this process, a new version of the app should
-              be available now.
-            </Text>
-          </View>
-          <View style={s.flex1} />
-          <View style={s.footerSpacer} />
-        </View>
-      </View>
-    )
-  }
-
-  if (!store.session.hasSession) {
-    return (
-      <View style={styles.outerContainer}>
-        <StatusBar
-          barStyle={
-            theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
-          }
-        />
-        <Login />
-        <ModalsContainer />
-      </View>
-    )
-  }
-
-  const screenBg = {
-    backgroundColor: theme.colorScheme === 'dark' ? colors.black : colors.gray1,
-  }
-  return (
-    <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
-      <StatusBar
-        barStyle={
-          theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
-        }
-      />
-      <View style={[styles.innerContainer, {paddingTop: safeAreaInsets.top}]}>
-        <HorzSwipe
-          distThresholdDivisor={2.5}
-          useNativeDriver
-          panX={swipeGestureInterp}
-          swipeEnabled
-          canSwipeLeft={canSwipeLeft}
-          canSwipeRight={canSwipeRight}
-          onSwipeStartDirection={onNavSwipeStartDirection}
-          onSwipeEnd={onNavSwipeEnd}>
-          <ScreenContainer style={styles.screenContainer}>
-            {screenRenderDesc.screens.map(
-              ({Com, navIdx, params, key, current, previous}) => {
-                if (isMenuActive) {
-                  // HACK menu is active, treat current as previous
-                  if (previous) {
-                    previous = false
-                  } else if (current) {
-                    current = false
-                    previous = true
-                  }
-                }
-                return (
-                  <Screen
-                    key={key}
-                    style={[StyleSheet.absoluteFill]}
-                    activityState={current ? 2 : previous ? 1 : 0}>
-                    <Animated.View
-                      style={
-                        current ? [styles.screenMask, swipeOpacity] : undefined
-                      }
-                    />
-                    <Animated.View
-                      style={[
-                        s.h100pct,
-                        screenBg,
-                        current ? [swipeTransform] : undefined,
-                      ]}>
-                      <ErrorBoundary>
-                        <Com
-                          params={params}
-                          navIdx={navIdx}
-                          visible={current}
-                        />
-                      </ErrorBoundary>
-                    </Animated.View>
-                  </Screen>
-                )
-              },
-            )}
-          </ScreenContainer>
-          <BottomBar />
-          {isMenuActive || menuSwipingDirection !== 0 ? (
-            <TouchableWithoutFeedback
-              onPress={() => store.shell.setMainMenuOpen(false)}>
-              <Animated.View style={[styles.screenMask, menuSwipeOpacity]} />
-            </TouchableWithoutFeedback>
-          ) : undefined}
-          {shouldRenderMenu && (
-            <Animated.View style={[styles.menuDrawer, menuSwipeTransform]}>
-              <Menu onClose={() => store.shell.setMainMenuOpen(false)} />
-            </Animated.View>
-          )}
-        </HorzSwipe>
-      </View>
-      <ModalsContainer />
-      <Lightbox />
-      <Composer
-        active={store.shell.isComposerActive}
-        onClose={() => store.shell.closeComposer()}
-        winHeight={winDim.height}
-        replyTo={store.shell.composerOpts?.replyTo}
-        imagesOpen={store.shell.composerOpts?.imagesOpen}
-        onPost={store.shell.composerOpts?.onPost}
-        quote={store.shell.composerOpts?.quote}
-      />
-    </View>
-  )
-})
-
-/**
- * This method produces the information needed by the shell to
- * render the current screens with screen-caching behaviors.
- */
-type ScreenRenderDesc = MatchResult & {
-  key: string
-  navIdx: string
-  current: boolean
-  previous: boolean
-  isNewTab: boolean
-}
-function constructScreenRenderDesc(nav: NavigationModel): {
-  icon: IconProp
-  hasNewTab: boolean
-  screens: ScreenRenderDesc[]
-} {
-  let hasNewTab = false
-  let icon: IconProp = 'magnifying-glass'
-  let screens: ScreenRenderDesc[] = []
-  for (const tab of nav.tabs) {
-    const tabScreens = [
-      ...tab.getBackList(5),
-      Object.assign({}, tab.current, {index: tab.index}),
-    ]
-    const parsedTabScreens = tabScreens.map(screen => {
-      const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
-      const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
-      const matchRes = match(screen.url)
-      if (isCurrent) {
-        icon = matchRes.icon
-      }
-      hasNewTab = hasNewTab || tab.isNewTab
-      return Object.assign(matchRes, {
-        key: `t${tab.id}-s${screen.index}`,
-        navIdx: `${tab.id}-${screen.id}`,
-        current: isCurrent,
-        previous: isPrevious,
-        isNewTab: tab.isNewTab,
-      }) as ScreenRenderDesc
-    })
-    screens = screens.concat(parsedTabScreens)
-  }
-  return {
-    icon,
-    hasNewTab,
-    screens,
-  }
-}
-
-const styles = StyleSheet.create({
-  outerContainer: {
-    height: '100%',
-  },
-  innerContainer: {
-    height: '100%',
-  },
-  screenContainer: {
-    height: '100%',
-  },
-  screenMask: {
-    position: 'absolute',
-    top: 0,
-    bottom: 0,
-    left: 0,
-    right: 0,
-    backgroundColor: '#000',
-    opacity: 0.6,
-  },
-  menuDrawer: {
-    position: 'absolute',
-    top: 0,
-    bottom: 0,
-    left: 0,
-    right: 100,
-  },
-  topBarProtector: {
-    position: 'absolute',
-    top: 0,
-    left: 0,
-    right: 0,
-    height: 50, // will be overwritten by insets
-    backgroundColor: colors.white,
-  },
-  topBarProtectorDark: {
-    backgroundColor: colors.black,
-  },
-})
diff --git a/src/view/shell/web/DesktopHeader.tsx b/src/view/shell/web/DesktopHeader.tsx
deleted file mode 100644
index 8748ebbde..000000000
--- a/src/view/shell/web/DesktopHeader.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-import React from 'react'
-import {observer} from 'mobx-react-lite'
-import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Text} from 'view/com/util/text/Text'
-import {UserAvatar} from 'view/com/util/UserAvatar'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
-import {useStores} from 'state/index'
-import {colors} from 'lib/styles'
-import {
-  ComposeIcon,
-  HomeIcon,
-  HomeIconSolid,
-  BellIcon,
-  BellIconSolid,
-  MagnifyingGlassIcon,
-  CogIcon,
-} from 'lib/icons'
-import {DesktopSearch} from './DesktopSearch'
-
-interface NavItemProps {
-  count?: number
-  href: string
-  icon: JSX.Element
-  iconFilled: JSX.Element
-  isProfile?: boolean
-}
-export const NavItem = observer(
-  ({count, href, icon, iconFilled}: NavItemProps) => {
-    const store = useStores()
-    const hoverBg = useColorSchemeStyle(
-      styles.navItemHoverBgLight,
-      styles.navItemHoverBgDark,
-    )
-    const isCurrent = store.nav.tab.current.url === href
-    const onPress = () => store.nav.navigate(href)
-    return (
-      <Pressable
-        style={state => [
-          styles.navItem,
-          // @ts-ignore Pressable state differs for RNW -prf
-          (state.hovered || isCurrent) && hoverBg,
-        ]}
-        onPress={onPress}>
-        <View style={[styles.navItemIconWrapper]}>
-          {isCurrent ? iconFilled : icon}
-          {typeof count === 'number' && count > 0 && (
-            <Text type="button" style={styles.navItemCount}>
-              {count}
-            </Text>
-          )}
-        </View>
-      </Pressable>
-    )
-  },
-)
-
-export const ProfileItem = observer(() => {
-  const store = useStores()
-  const hoverBg = useColorSchemeStyle(
-    styles.navItemHoverBgLight,
-    styles.navItemHoverBgDark,
-  )
-  const href = `/profile/${store.me.handle}`
-  const isCurrent = store.nav.tab.current.url === href
-  const onPress = () => store.nav.navigate(href)
-  return (
-    <Pressable
-      style={state => [
-        styles.navItem,
-        // @ts-ignore Pressable state differs for RNW -prf
-        (state.hovered || isCurrent) && hoverBg,
-      ]}
-      onPress={onPress}>
-      <View style={[styles.navItemIconWrapper]}>
-        <UserAvatar
-          handle={store.me.handle}
-          displayName={store.me.displayName}
-          avatar={store.me.avatar}
-          size={28}
-        />
-      </View>
-    </Pressable>
-  )
-})
-
-export const DesktopHeader = observer(function DesktopHeader({}: {
-  canGoBack?: boolean
-}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const onPressCompose = () => store.shell.openComposer({})
-
-  return (
-    <View style={[styles.header, pal.borderDark, pal.view]}>
-      <Text type="title-xl" style={[pal.text, styles.title]}>
-        Bluesky
-      </Text>
-      <View style={styles.space30} />
-      <NavItem
-        href="/"
-        icon={<HomeIcon size={24} />}
-        iconFilled={<HomeIconSolid size={24} />}
-      />
-      <View style={styles.space15} />
-      <NavItem
-        href="/search"
-        icon={<MagnifyingGlassIcon size={24} />}
-        iconFilled={<MagnifyingGlassIcon strokeWidth={3} size={24} />}
-      />
-      <View style={styles.space15} />
-      <NavItem
-        href="/notifications"
-        count={store.me.notifications.unreadCount}
-        icon={<BellIcon size={24} />}
-        iconFilled={<BellIconSolid size={24} />}
-      />
-      <View style={styles.spaceFlex} />
-      <TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}>
-        <View style={styles.newPostBtnIconWrapper}>
-          <ComposeIcon
-            size={16}
-            strokeWidth={2}
-            style={styles.newPostBtnLabel}
-          />
-        </View>
-        <Text type="md" style={styles.newPostBtnLabel}>
-          New Post
-        </Text>
-      </TouchableOpacity>
-      <View style={styles.space20} />
-      <DesktopSearch />
-      <View style={styles.space15} />
-      <ProfileItem />
-      <NavItem
-        href="/settings"
-        icon={<CogIcon strokeWidth={2} size={28} />}
-        iconFilled={<CogIcon strokeWidth={2.5} size={28} />}
-      />
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  header: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    // paddingTop: 18,
-    // paddingBottom: 18,
-    paddingLeft: 30,
-    paddingRight: 40,
-    borderBottomWidth: 1,
-    zIndex: 1,
-  },
-
-  spaceFlex: {
-    flex: 1,
-  },
-  space15: {
-    width: 15,
-  },
-  space20: {
-    width: 20,
-  },
-  space30: {
-    width: 30,
-  },
-
-  title: {},
-
-  navItem: {
-    paddingTop: 14,
-    paddingBottom: 10,
-    paddingHorizontal: 10,
-    alignItems: 'center',
-    borderBottomWidth: 2,
-    borderBottomColor: 'transparent',
-  },
-  navItemHoverBgLight: {
-    borderBottomWidth: 2,
-    borderBottomColor: colors.blue3,
-  },
-  navItemHoverBgDark: {
-    borderBottomWidth: 2,
-    backgroundColor: colors.blue3,
-  },
-  navItemIconWrapper: {
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: 28,
-    height: 28,
-    marginBottom: 2,
-  },
-  navItemCount: {
-    position: 'absolute',
-    top: 0,
-    left: 15,
-    backgroundColor: colors.red3,
-    color: colors.white,
-    fontSize: 12,
-    fontWeight: 'bold',
-    paddingHorizontal: 4,
-    borderRadius: 6,
-  },
-
-  newPostBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 24,
-    paddingTop: 8,
-    paddingBottom: 8,
-    paddingHorizontal: 18,
-    backgroundColor: colors.blue3,
-  },
-  newPostBtnIconWrapper: {
-    marginRight: 8,
-  },
-  newPostBtnLabel: {
-    color: colors.white,
-  },
-})
diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx
deleted file mode 100644
index a76ae8060..000000000
--- a/src/view/shell/web/index.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import React from 'react'
-import {observer} from 'mobx-react-lite'
-import {View, StyleSheet} from 'react-native'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
-import {useStores} from 'state/index'
-import {NavigationModel} from 'state/models/navigation'
-import {match, MatchResult} from '../../routes'
-import {DesktopHeader} from './DesktopHeader'
-import {Login} from '../../screens/Login'
-import {ErrorBoundary} from '../../com/util/ErrorBoundary'
-import {Lightbox} from '../../com/lightbox/Lightbox'
-import {ModalsContainer} from '../../com/modals/Modal'
-import {Text} from 'view/com/util/text/Text'
-import {Composer} from './Composer'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
-import {s, colors} from 'lib/styles'
-import {isMobileWeb} from 'platform/detection'
-
-export const WebShell: React.FC = observer(() => {
-  const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
-  const store = useStores()
-  const screenRenderDesc = constructScreenRenderDesc(store.nav)
-
-  if (isMobileWeb) {
-    return <NoMobileWeb />
-  }
-
-  if (!store.session.hasSession) {
-    return (
-      <View style={styles.outerContainer}>
-        <Login />
-        <ModalsContainer />
-      </View>
-    )
-  }
-
-  return (
-    <View style={[styles.outerContainer, pageBg]}>
-      <DesktopHeader />
-      {screenRenderDesc.screens.map(({Com, navIdx, params, key, current}) => (
-        <View
-          key={key}
-          style={[s.hContentRegion, current ? styles.visible : styles.hidden]}>
-          <ErrorBoundary>
-            <Com params={params} navIdx={navIdx} visible={current} />
-          </ErrorBoundary>
-        </View>
-      ))}
-      <Composer
-        active={store.shell.isComposerActive}
-        onClose={() => store.shell.closeComposer()}
-        winHeight={0}
-        replyTo={store.shell.composerOpts?.replyTo}
-        imagesOpen={store.shell.composerOpts?.imagesOpen}
-        onPost={store.shell.composerOpts?.onPost}
-      />
-      <ModalsContainer />
-      <Lightbox />
-    </View>
-  )
-})
-
-/**
- * This method produces the information needed by the shell to
- * render the current screens with screen-caching behaviors.
- */
-type ScreenRenderDesc = MatchResult & {
-  key: string
-  navIdx: string
-  current: boolean
-  previous: boolean
-  isNewTab: boolean
-}
-function constructScreenRenderDesc(nav: NavigationModel): {
-  icon: IconProp
-  hasNewTab: boolean
-  screens: ScreenRenderDesc[]
-} {
-  let hasNewTab = false
-  let icon: IconProp = 'magnifying-glass'
-  let screens: ScreenRenderDesc[] = []
-  for (const tab of nav.tabs) {
-    const tabScreens = [
-      ...tab.getBackList(5),
-      Object.assign({}, tab.current, {index: tab.index}),
-    ]
-    const parsedTabScreens = tabScreens.map(screen => {
-      const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
-      const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
-      const matchRes = match(screen.url)
-      if (isCurrent) {
-        icon = matchRes.icon
-      }
-      hasNewTab = hasNewTab || tab.isNewTab
-      return Object.assign(matchRes, {
-        key: `t${tab.id}-s${screen.index}`,
-        navIdx: `${tab.id}-${screen.id}`,
-        current: isCurrent,
-        previous: isPrevious,
-        isNewTab: tab.isNewTab,
-      }) as ScreenRenderDesc
-    })
-    screens = screens.concat(parsedTabScreens)
-  }
-  return {
-    icon,
-    hasNewTab,
-    screens,
-  }
-}
-
-function NoMobileWeb() {
-  const pal = usePalette('default')
-  return (
-    <View style={[pal.view, styles.noMobileWeb]}>
-      <Text type="title-2xl" style={s.pb20}>
-        We're so sorry!
-      </Text>
-      <Text type="lg">
-        This app is not available for mobile Web yet. Please open it on your
-        desktop or download the iOS app.
-      </Text>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  outerContainer: {
-    height: '100%',
-  },
-  bgLight: {
-    backgroundColor: colors.white,
-  },
-  bgDark: {
-    backgroundColor: colors.black, // TODO
-  },
-  visible: {
-    display: 'flex',
-  },
-  hidden: {
-    display: 'none',
-  },
-  noMobileWeb: {
-    height: '100%',
-    justifyContent: 'center',
-    paddingHorizontal: 20,
-    paddingBottom: 40,
-  },
-})