about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-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
74 files changed, 2707 insertions, 2704 deletions
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,
-  },
-})