about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-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
44 files changed, 1439 insertions, 1104 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,