about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/Composer.tsx132
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx12
-rw-r--r--src/view/com/composer/photos/Gallery.tsx130
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx58
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.web.tsx3
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx84
-rw-r--r--src/view/com/composer/photos/SelectedPhotos.tsx96
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx111
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx42
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
-rw-r--r--src/view/com/modals/EditProfile.tsx14
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx12
-rw-r--r--src/view/com/util/UserAvatar.tsx16
-rw-r--r--src/view/com/util/UserBanner.tsx15
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx12
15 files changed, 350 insertions, 395 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 7d72899fc..f77005b5e 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
@@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {cleanError} from 'lib/strings/errors'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
-import {SelectedPhotos} from './photos/SelectedPhotos'
 import {usePalette} from 'lib/hooks/usePalette'
 import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {isDesktopWeb} from 'platform/detection'
+import {GalleryModel} from 'state/models/media/gallery'
+import {Gallery} from './photos/Gallery'
 
 const MAX_GRAPHEME_LENGTH = 300
 
+type Props = ComposerOpts & {
+  onClose: () => void
+}
+
 export const ComposePost = observer(function ComposePost({
   replyTo,
   onPost,
   onClose,
   quote: initQuote,
-}: {
-  replyTo?: ComposerOpts['replyTo']
-  onPost?: ComposerOpts['onPost']
-  onClose: () => void
-  quote?: ComposerOpts['quote']
-}) {
+}: Props) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const textInput = React.useRef<TextInputRef>(null)
-  const [isProcessing, setIsProcessing] = React.useState(false)
-  const [processingState, setProcessingState] = React.useState('')
-  const [error, setError] = React.useState('')
-  const [richtext, setRichText] = React.useState(new RichText({text: ''}))
-  const graphemeLength = React.useMemo(
-    () => richtext.graphemeLength,
-    [richtext],
-  )
-  const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
+  const textInput = useRef<TextInputRef>(null)
+  const [isProcessing, setIsProcessing] = useState(false)
+  const [processingState, setProcessingState] = useState('')
+  const [error, setError] = useState('')
+  const [richtext, setRichText] = useState(new RichText({text: ''}))
+  const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
+  const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
-  const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
-    new Set(),
-  )
-  const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
+  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
+  const gallery = useMemo(() => new GalleryModel(store), [store])
 
-  const autocompleteView = React.useMemo<UserAutocompleteModel>(
+  const autocompleteView = useMemo<UserAutocompleteModel>(
     () => new UserAutocompleteModel(store),
     [store],
   )
@@ -82,17 +77,17 @@ 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 = React.useCallback(() => {
+  const hackfixOnClose = useCallback(() => {
     textInput.current?.blur()
     onClose()
   }, [textInput, onClose])
 
   // initial setup
-  React.useEffect(() => {
+  useEffect(() => {
     autocompleteView.setup()
   }, [autocompleteView])
 
-  React.useEffect(() => {
+  useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
     // -prf
@@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [])
 
-  const onPressContainer = React.useCallback(() => {
+  const onPressContainer = useCallback(() => {
     textInput.current?.focus()
   }, [textInput])
 
-  const onSelectPhotos = React.useCallback(
-    (photos: string[]) => {
-      track('Composer:SelectedPhotos')
-      setSelectedPhotos(photos)
-    },
-    [track, setSelectedPhotos],
-  )
-
-  const onPressAddLinkCard = React.useCallback(
+  const onPressAddLinkCard = useCallback(
     (uri: string) => {
       setExtLink({uri, isLoading: true})
     },
     [setExtLink],
   )
 
-  const onPhotoPasted = React.useCallback(
+  const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (selectedPhotos.length >= 4) {
-        return
-      }
-      onSelectPhotos([...selectedPhotos, uri])
+      track('Composer:PastedPhotos')
+      gallery.paste(uri)
     },
-    [selectedPhotos, onSelectPhotos],
+    [gallery, track],
   )
 
-  const onPressPublish = React.useCallback(async () => {
-    if (isProcessing) {
-      return
-    }
-    if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
+  const onPressPublish = useCallback(async () => {
+    if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
+
     setError('')
-    if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
+
+    if (richtext.text.trim().length === 0 && gallery.isEmpty) {
       setError('Did you want to say anything?')
       return false
     }
+
     setIsProcessing(true)
+
     try {
       await apilib.post(store, {
         rawText: richtext.text,
         replyTo: replyTo?.uri,
-        images: selectedPhotos,
+        images: gallery.paths,
         quote: quote,
         extLink: extLink,
         onStateChange: setProcessingState,
         knownHandles: autocompleteView.knownHandles,
       })
       track('Create Post', {
-        imageCount: selectedPhotos.length,
+        imageCount: gallery.size,
       })
     } catch (e: any) {
       if (extLink) {
@@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({
     hackfixOnClose,
     onPost,
     quote,
-    selectedPhotos,
     setExtLink,
     store,
     track,
+    gallery,
   ])
 
   const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
 
   const selectTextInputPlaceholder = replyTo
     ? 'Write your reply'
-    : selectedPhotos.length !== 0
+    : gallery.isEmpty
     ? 'Write a comment'
     : "What's up?"
 
+  const canSelectImages = gallery.size <= 4
+  const viewStyles = {
+    paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
+    paddingTop: Platform.OS === 'android' ? insets.top : 15,
+  }
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
       behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
       style={styles.outer}>
       <TouchableWithoutFeedback onPressIn={onPressContainer}>
-        <View
-          style={[
-            s.flex1,
-            {
-              paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
-              paddingTop: Platform.OS === 'android' ? insets.top : 15,
-            },
-          ]}>
+        <View style={[s.flex1, viewStyles]}>
           <View style={styles.topbar}>
             <TouchableOpacity
               testID="composerCancelButton"
@@ -301,11 +286,8 @@ export const ComposePost = observer(function ComposePost({
               />
             </View>
 
-            <SelectedPhotos
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={onSelectPhotos}
-            />
-            {selectedPhotos.length === 0 && extLink && (
+            <Gallery gallery={gallery} />
+            {gallery.isEmpty && extLink && (
               <ExternalEmbed
                 link={extLink}
                 onRemove={() => setExtLink(undefined)}
@@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({
               </View>
             ) : undefined}
           </ScrollView>
-          {!extLink &&
-          selectedPhotos.length === 0 &&
-          suggestedLinks.size > 0 ? (
+          {!extLink && suggestedLinks.size > 0 ? (
             <View style={s.mb5}>
               {Array.from(suggestedLinks).map(url => (
                 <TouchableOpacity
@@ -335,16 +315,12 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : null}
           <View style={[pal.border, styles.bottomBar]}>
-            <SelectPhotoBtn
-              enabled={selectedPhotos.length < 4}
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={setSelectedPhotos}
-            />
-            <OpenCameraBtn
-              enabled={selectedPhotos.length < 4}
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={setSelectedPhotos}
-            />
+            {canSelectImages ? (
+              <>
+                <SelectPhotoBtn gallery={gallery} />
+                <OpenCameraBtn gallery={gallery} />
+              </>
+            ) : null}
             <View style={s.flex1} />
             <CharProgress count={graphemeLength} />
           </View>
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 658023330..b6a45f6a3 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -2,11 +2,10 @@ import React from 'react'
 import {
   ActivityIndicator,
   StyleSheet,
-  TouchableWithoutFeedback,
+  TouchableOpacity,
   View,
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {BlurView} from '../util/BlurView'
 import {AutoSizedImage} from '../util/images/AutoSizedImage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
@@ -61,11 +60,9 @@ export const ExternalEmbed = ({
           </Text>
         )}
       </View>
-      <TouchableWithoutFeedback onPress={onRemove}>
-        <BlurView style={styles.removeBtn} blurType="dark">
-          <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
-        </BlurView>
-      </TouchableWithoutFeedback>
+      <TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
+        <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
+      </TouchableOpacity>
     </View>
   )
 }
@@ -92,6 +89,7 @@ const styles = StyleSheet.create({
     right: 10,
     width: 36,
     height: 36,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 18,
     alignItems: 'center',
     justifyContent: 'center',
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
new file mode 100644
index 000000000..f4dfc88fa
--- /dev/null
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -0,0 +1,130 @@
+import React, {useCallback} from 'react'
+import {GalleryModel} from 'state/models/media/gallery'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from 'lib/styles'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {ImageModel} from 'state/models/media/image'
+import {Image} from 'expo-image'
+
+interface Props {
+  gallery: GalleryModel
+}
+
+export const Gallery = observer(function ({gallery}: Props) {
+  const getImageStyle = useCallback(() => {
+    switch (gallery.size) {
+      case 1:
+        return styles.image250
+      case 2:
+        return styles.image175
+      default:
+        return styles.image85
+    }
+  }, [gallery])
+
+  const imageStyle = getImageStyle()
+  const handleRemovePhoto = useCallback(
+    (image: ImageModel) => {
+      gallery.remove(image)
+    },
+    [gallery],
+  )
+
+  const handleEditPhoto = useCallback(
+    (image: ImageModel) => {
+      gallery.crop(image)
+    },
+    [gallery],
+  )
+
+  return !gallery.isEmpty ? (
+    <View testID="selectedPhotosView" style={styles.gallery}>
+      {gallery.images.map(image =>
+        image.compressed !== undefined ? (
+          <View
+            key={`selected-image-${image.path}`}
+            style={[styles.imageContainer, imageStyle]}>
+            <View style={styles.imageControls}>
+              <TouchableOpacity
+                testID="cropPhotoButton"
+                onPress={() => {
+                  handleEditPhoto(image)
+                }}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="pen"
+                  size={12}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+              <TouchableOpacity
+                testID="removePhotoButton"
+                onPress={() => handleRemovePhoto(image)}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="xmark"
+                  size={16}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+            </View>
+
+            <Image
+              testID="selectedPhotoImage"
+              style={[styles.image, imageStyle]}
+              source={{
+                uri: image.compressed.path,
+              }}
+            />
+          </View>
+        ) : null,
+      )}
+    </View>
+  ) : null
+})
+
+const styles = StyleSheet.create({
+  gallery: {
+    flex: 1,
+    flexDirection: 'row',
+    marginTop: 16,
+  },
+  imageContainer: {
+    margin: 2,
+  },
+  image: {
+    resizeMode: 'cover',
+    borderRadius: 8,
+  },
+  image250: {
+    width: 250,
+    height: 250,
+  },
+  image175: {
+    width: 175,
+    height: 175,
+  },
+  image85: {
+    width: 85,
+    height: 85,
+  },
+  imageControls: {
+    position: 'absolute',
+    display: 'flex',
+    flexDirection: 'row',
+    gap: 4,
+    top: 8,
+    right: 8,
+    zIndex: 1,
+  },
+  imageControl: {
+    width: 24,
+    height: 24,
+    borderRadius: 12,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderWidth: 0.5,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 118728781..809c41783 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {TouchableOpacity} from 'react-native'
 import {
   FontAwesomeIcon,
@@ -10,62 +10,44 @@ 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'
+import {POST_IMG_MAX} from 'lib/constants'
+import {GalleryModel} from 'state/models/media/gallery'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
-export function OpenCameraBtn({
-  enabled,
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  enabled: boolean
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) {
+type Props = {
+  gallery: GalleryModel
+}
+
+export function OpenCameraBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const store = useStores()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
 
-  const onPressTakePicture = React.useCallback(async () => {
+  const onPressTakePicture = 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,
+
+      const img = await openCamera(store, {
+        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])
+
+      gallery.add(img)
     } catch (err: any) {
       // ignore
       store.log.warn('Error using camera', err)
     }
-  }, [
-    track,
-    store,
-    onSelectPhotos,
-    selectedPhotos,
-    enabled,
-    requestCameraAccessIfNeeded,
-  ])
+  }, [gallery, track, store, requestCameraAccessIfNeeded])
 
   if (isDesktopWeb) {
-    return <></>
+    return null
   }
 
   return (
@@ -76,11 +58,7 @@ export function OpenCameraBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon="camera"
-        style={
-          (enabled
-            ? pal.link
-            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
-        }
+        style={pal.link as FontAwesomeIconStyle}
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.web.tsx b/src/view/com/composer/photos/OpenCameraBtn.web.tsx
new file mode 100644
index 000000000..226de1f60
--- /dev/null
+++ b/src/view/com/composer/photos/OpenCameraBtn.web.tsx
@@ -0,0 +1,3 @@
+export function OpenCameraBtn() {
+  return null
+}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index c0808b85c..9569e08ad 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -1,86 +1,36 @@
-import React from 'react'
-import {Platform, TouchableOpacity} from 'react-native'
+import React, {useCallback} 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'
+import {GalleryModel} from 'state/models/media/gallery'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
-export function SelectPhotoBtn({
-  enabled,
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  enabled: boolean
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) {
+type Props = {
+  gallery: GalleryModel
+}
+
+export function SelectPhotoBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const store = useStores()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
-  const onPressSelectPhotos = React.useCallback(async () => {
+  const onPressSelectPhotos = useCallback(async () => {
     track('Composer:GalleryOpened')
-    if (!enabled) {
+
+    if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
       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) {
-        if (Platform.OS === 'android') {
-          result.push(image.path)
-          continue
-        }
-        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,
-  ])
+
+    gallery.pick()
+  }, [track, gallery, requestPhotoAccessIfNeeded])
 
   return (
     <TouchableOpacity
@@ -90,11 +40,7 @@ export function SelectPhotoBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon={['far', 'image']}
-        style={
-          (enabled
-            ? pal.link
-            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
-        }
+        style={pal.link as FontAwesomeIconStyle}
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/photos/SelectedPhotos.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx
deleted file mode 100644
index d22f5d8c4..000000000
--- a/src/view/com/composer/photos/SelectedPhotos.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React, {useCallback} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Image} from 'expo-image'
-import {colors} from 'lib/styles'
-
-export const SelectedPhotos = ({
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) => {
-  const imageStyle =
-    selectedPhotos.length === 1
-      ? styles.image250
-      : selectedPhotos.length === 2
-      ? styles.image175
-      : styles.image85
-
-  const handleRemovePhoto = useCallback(
-    item => {
-      onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
-    },
-    [selectedPhotos, onSelectPhotos],
-  )
-
-  return selectedPhotos.length !== 0 ? (
-    <View testID="selectedPhotosView" style={styles.gallery}>
-      {selectedPhotos.length !== 0 &&
-        selectedPhotos.map((item, index) => (
-          <View
-            key={`selected-image-${index}`}
-            style={[styles.imageContainer, imageStyle]}>
-            <TouchableOpacity
-              testID="removePhotoButton"
-              onPress={() => handleRemovePhoto(item)}
-              style={styles.removePhotoButton}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                style={{color: colors.white}}
-              />
-            </TouchableOpacity>
-
-            <Image
-              testID="selectedPhotoImage"
-              style={[styles.image, imageStyle]}
-              source={{uri: item}}
-            />
-          </View>
-        ))}
-    </View>
-  ) : null
-}
-
-const styles = StyleSheet.create({
-  gallery: {
-    flex: 1,
-    flexDirection: 'row',
-    marginTop: 16,
-  },
-  imageContainer: {
-    margin: 2,
-  },
-  image: {
-    resizeMode: 'cover',
-    borderRadius: 8,
-  },
-  image250: {
-    width: 250,
-    height: 250,
-  },
-  image175: {
-    width: 175,
-    height: 175,
-  },
-  image85: {
-    width: 85,
-    height: 85,
-  },
-  removePhotoButton: {
-    position: 'absolute',
-    top: 8,
-    right: 8,
-    width: 24,
-    height: 24,
-    borderRadius: 12,
-    alignItems: 'center',
-    justifyContent: 'center',
-    backgroundColor: colors.black,
-    zIndex: 1,
-    borderColor: colors.gray4,
-    borderWidth: 0.5,
-  },
-})
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index bd536e1c3..9c111bd38 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react'
 import {
   NativeSyntheticEvent,
   StyleSheet,
@@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal'
 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 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 {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'
+import {isUriImage} from 'lib/media/util'
+import {downloadAndResize} from 'lib/media/manip'
+import {POST_IMG_MAX} from 'lib/constants'
 
 export interface TextInputRef {
   focus: () => void
@@ -48,7 +43,7 @@ interface Selection {
   end: number
 }
 
-export const TextInput = React.forwardRef(
+export const TextInput = forwardRef(
   (
     {
       richtext,
@@ -63,9 +58,8 @@ export const TextInput = React.forwardRef(
     ref,
   ) => {
     const pal = usePalette('default')
-    const store = useStores()
-    const textInput = React.useRef<PasteInputRef>(null)
-    const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
+    const textInput = useRef<PasteInputRef>(null)
+    const textInputSelection = useRef<Selection>({start: 0, end: 0})
     const theme = useTheme()
 
     React.useImperativeHandle(ref, () => ({
@@ -73,7 +67,7 @@ export const TextInput = React.forwardRef(
       blur: () => textInput.current?.blur(),
     }))
 
-    React.useEffect(() => {
+    useEffect(() => {
       // HACK
       // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
       // -prf
@@ -90,8 +84,8 @@ export const TextInput = React.forwardRef(
       }
     }, [])
 
-    const onChangeText = React.useCallback(
-      (newText: string) => {
+    const onChangeText = useCallback(
+      async (newText: string) => {
         const newRt = new RichText({text: newText})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
@@ -108,50 +102,62 @@ export const TextInput = React.forwardRef(
         }
 
         const set: Set<string> = new Set()
+
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
               if (AppBskyRichtextFacet.isLink(feature)) {
-                set.add(feature.uri)
+                if (isUriImage(feature.uri)) {
+                  const res = await downloadAndResize({
+                    uri: feature.uri,
+                    width: POST_IMG_MAX.width,
+                    height: POST_IMG_MAX.height,
+                    mode: 'contain',
+                    maxSize: POST_IMG_MAX.size,
+                    timeout: 15e3,
+                  })
+
+                  if (res !== undefined) {
+                    onPhotoPasted(res.path)
+                  }
+                } else {
+                  set.add(feature.uri)
+                }
               }
             }
           }
         }
+
         if (!isEqual(set, suggestedLinks)) {
           onSuggestedLinksChanged(set)
         }
       },
-      [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
+      [
+        setRichText,
+        autocompleteView,
+        suggestedLinks,
+        onSuggestedLinksChanged,
+        onPhotoPasted,
+      ],
     )
 
-    const onPaste = React.useCallback(
+    const onPaste = 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)
+        const uri = uris.find(isUriImage)
+
+        if (uri) {
+          onPhotoPasted(uri)
         }
       },
-      [store, onError, onPhotoPasted],
+      [onError, onPhotoPasted],
     )
 
-    const onSelectionChange = React.useCallback(
+    const onSelectionChange = useCallback(
       (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
         // NOTE we track the input selection using a ref to avoid excessive renders -prf
         textInputSelection.current = evt.nativeEvent.selection
@@ -159,7 +165,7 @@ export const TextInput = React.forwardRef(
       [textInputSelection],
     )
 
-    const onSelectAutocompleteItem = React.useCallback(
+    const onSelectAutocompleteItem = useCallback(
       (item: string) => {
         onChangeText(
           insertMentionAt(
@@ -173,23 +179,19 @@ export const TextInput = React.forwardRef(
       [onChangeText, richtext, autocompleteView],
     )
 
-    const textDecorated = React.useMemo(() => {
+    const textDecorated = useMemo(() => {
       let i = 0
-      return Array.from(richtext.segments()).map(segment => {
-        if (!segment.facet) {
-          return (
-            <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
-              {segment.text}
-            </Text>
-          )
-        } else {
-          return (
-            <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
-              {segment.text}
-            </Text>
-          )
-        }
-      })
+
+      return Array.from(richtext.segments()).map(segment => (
+        <Text
+          key={i++}
+          style={[
+            !segment.facet ? pal.text : pal.link,
+            styles.textInputFormatting,
+          ]}>
+          {segment.text}
+        </Text>
+      ))
     }, [richtext, pal.link, pal.text])
 
     return (
@@ -223,7 +225,6 @@ const styles = StyleSheet.create({
   textInput: {
     flex: 1,
     width: '100%',
-    minHeight: 80,
     padding: 5,
     paddingBottom: 20,
     marginLeft: 8,
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index ba628a3f7..e75da1791 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal'
 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+import {isUriImage, blobToDataUri} from 'lib/media/util'
 
 export interface TextInputRef {
   focus: () => void
@@ -37,7 +38,7 @@ export const TextInput = React.forwardRef(
       suggestedLinks,
       autocompleteView,
       setRichText,
-      // onPhotoPasted, TODO
+      onPhotoPasted,
       onSuggestedLinksChanged,
     }: // onError, TODO
     TextInputProps,
@@ -72,6 +73,15 @@ export const TextInput = React.forwardRef(
           attributes: {
             class: modeClass,
           },
+          handlePaste: (_, event) => {
+            const items = event.clipboardData?.items
+
+            if (items === undefined) {
+              return
+            }
+
+            getImageFromUri(items, onPhotoPasted)
+          },
         },
         content: richtext.text.toString(),
         autofocus: true,
@@ -147,3 +157,33 @@ const styles = StyleSheet.create({
     marginBottom: 10,
   },
 })
+
+function getImageFromUri(
+  items: DataTransferItemList,
+  callback: (uri: string) => void,
+) {
+  for (let index = 0; index < items.length; index++) {
+    const item = items[index]
+    const {kind, type} = item
+
+    if (type === 'text/plain') {
+      item.getAsString(async itemString => {
+        if (isUriImage(itemString)) {
+          const response = await fetch(itemString)
+          const blob = await response.blob()
+          blobToDataUri(blob).then(callback, err => console.error(err))
+        }
+      })
+    }
+
+    if (kind === 'file') {
+      const file = item.getAsFile()
+
+      if (file instanceof Blob) {
+        blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
+          console.error(err),
+        )
+      }
+    }
+  }
+}
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 9cb91231c..45c2dfd0d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -6,6 +6,7 @@ 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/ui/shell'
+import {POST_IMG_MAX} from 'lib/constants'
 
 export function useExternalLinkFetch({
   setQuote,
@@ -55,13 +56,12 @@ export function useExternalLinkFetch({
       return cleanup
     }
     if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
-      console.log('attempting download')
       downloadAndResize({
         uri: extLink.meta.image,
-        width: 2000,
-        height: 2000,
+        width: POST_IMG_MAX.width,
+        height: POST_IMG_MAX.height,
         mode: 'contain',
-        maxSize: 1000000,
+        maxSize: POST_IMG_MAX.size,
         timeout: 15e3,
       })
         .catch(() => undefined)
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index e6ef765af..0feae3a80 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -8,7 +8,7 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {ScrollView, TextInput} from './util'
-import {PickedMedia} from '../../../lib/media/picker'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {useStores} from 'state/index'
@@ -53,15 +53,15 @@ export function Component({
     profileView.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
-    PickedMedia | undefined | null
+    RNImage | undefined | null
   >()
   const [newUserAvatar, setNewUserAvatar] = useState<
-    PickedMedia | undefined | null
+    RNImage | undefined | null
   >()
   const onPressCancel = () => {
     store.shell.closeModal()
   }
-  const onSelectNewAvatar = async (img: PickedMedia | null) => {
+  const onSelectNewAvatar = async (img: RNImage | null) => {
     track('EditProfile:AvatarSelected')
     try {
       // if img is null, user selected "remove avatar"
@@ -71,13 +71,13 @@ export function Component({
         return
       }
       const finalImg = await compressIfNeeded(img, 1000000)
-      setNewUserAvatar({mediaType: 'photo', ...finalImg})
+      setNewUserAvatar(finalImg)
       setUserAvatar(finalImg.path)
     } catch (e: any) {
       setError(cleanError(e))
     }
   }
-  const onSelectNewBanner = async (img: PickedMedia | null) => {
+  const onSelectNewBanner = async (img: RNImage | null) => {
     if (!img) {
       setNewUserBanner(null)
       setUserBanner(null)
@@ -86,7 +86,7 @@ export function Component({
     track('EditProfile:BannerSelected')
     try {
       const finalImg = await compressIfNeeded(img, 1000000)
-      setNewUserBanner({mediaType: 'photo', ...finalImg})
+      setNewUserBanner(finalImg)
       setUserBanner(finalImg.path)
     } catch (e: any) {
       setError(cleanError(e))
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 306686557..8a9b4bf62 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor'
 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 {Dimensions, Image} from 'lib/media/types'
 import {getDataUriSize} from 'lib/media/util'
 import {s, gradients} from 'lib/styles'
 import {useStores} from 'state/index'
@@ -16,11 +16,8 @@ enum AspectRatio {
   Wide = 'wide',
   Tall = 'tall',
 }
-interface Dim {
-  width: number
-  height: number
-}
-const DIMS: Record<string, Dim> = {
+
+const DIMS: Record<string, Dimensions> = {
   [AspectRatio.Square]: {width: 1000, height: 1000},
   [AspectRatio.Wide]: {width: 1000, height: 750},
   [AspectRatio.Tall]: {width: 750, height: 1000},
@@ -33,7 +30,7 @@ export function Component({
   onSelect,
 }: {
   uri: string
-  onSelect: (img?: PickedMedia) => void
+  onSelect: (img?: Image) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -52,7 +49,6 @@ export function Component({
     if (canvas) {
       const dataUri = canvas.toDataURL('image/jpeg')
       onSelect({
-        mediaType: 'photo',
         path: dataUri,
         mime: 'image/jpeg',
         size: getDataUriSize(dataUri),
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d18c2d697..e5c3cf601 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -4,12 +4,7 @@ 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'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  PickedMedia,
-} from '../../../lib/media/picker'
+import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
   useCameraPermission,
@@ -19,6 +14,7 @@ import {colors} from 'lib/styles'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 function DefaultAvatar({size}: {size: number}) {
   return (
@@ -50,7 +46,7 @@ export function UserAvatar({
   size: number
   avatar?: string | null
   hasWarning?: boolean
-  onSelectNewAvatar?: (img: PickedMedia | null) => void
+  onSelectNewAvatar?: (img: RNImage | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -68,7 +64,6 @@ export function UserAvatar({
         }
         onSelectNewAvatar?.(
           await openCamera(store, {
-            mediaType: 'photo',
             width: 1000,
             height: 1000,
             cropperCircleOverlay: true,
@@ -84,9 +79,8 @@ export function UserAvatar({
         if (!(await requestPhotoAccessIfNeeded())) {
           return
         }
-        const items = await openPicker(store, {
-          mediaType: 'photo',
-        })
+        const items = await openPicker(store)
+
         onSelectNewAvatar?.(
           await openCropper(store, {
             mediaType: 'photo',
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index d54b41506..40c82eaf2 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  PickedMedia,
-} from '../../../lib/media/picker'
+import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
+import {Image as TImage} from 'lib/media/types'
 import {useStores} from 'state/index'
 import {
   usePhotoLibraryPermission,
@@ -24,7 +20,7 @@ export function UserBanner({
   onSelectNewBanner,
 }: {
   banner?: string | null
-  onSelectNewBanner?: (img: PickedMedia | null) => void
+  onSelectNewBanner?: (img: TImage | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -42,7 +38,6 @@ export function UserBanner({
         }
         onSelectNewBanner?.(
           await openCamera(store, {
-            mediaType: 'photo',
             // compressImageMaxWidth: 3000, TODO needed?
             width: 3000,
             // compressImageMaxHeight: 1000, TODO needed?
@@ -59,9 +54,7 @@ export function UserBanner({
         if (!(await requestPhotoAccessIfNeeded())) {
           return
         }
-        const items = await openPicker(store, {
-          mediaType: 'photo',
-        })
+        const items = await openPicker(store)
         onSelectNewBanner?.(
           await openCropper(store, {
             mediaType: 'photo',
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 83d98eec5..7f9a6fdbd 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,4 +1,5 @@
-import React from 'react'
+import {Dimensions} from 'lib/media/types'
+import React, {useState} from 'react'
 import {
   LayoutChangeEvent,
   StyleProp,
@@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image'
 
 export const DELAY_PRESS_IN = 500
 
-interface Dim {
-  width: number
-  height: number
-}
-
 export type ImageLayoutGridType = 'two' | 'three' | 'four'
 
 export function ImageLayoutGrid({
@@ -33,7 +29,7 @@ export function ImageLayoutGrid({
   onPressIn?: (index: number) => void
   style?: StyleProp<ViewStyle>
 }) {
-  const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
+  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
 
   const onLayout = (evt: LayoutChangeEvent) => {
     setContainerInfo({
@@ -71,7 +67,7 @@ function ImageLayoutGridInner({
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
-  containerInfo: Dim
+  containerInfo: Dimensions
 }) {
   const size1 = React.useMemo<ImageStyle>(() => {
     if (type === 'three') {