about summary refs log tree commit diff
path: root/src/view/com/composer
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer')
-rw-r--r--src/view/com/composer/Composer.tsx67
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx2
-rw-r--r--src/view/com/composer/GifAltText.tsx2
-rw-r--r--src/view/com/composer/photos/Gallery.tsx336
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx13
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx21
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts7
7 files changed, 247 insertions, 201 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index dfdfb3ebd..3b7cf1385 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import * as apilib from '#/lib/api/index'
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
+import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {useModals} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   toPostLanguages,
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import * as Prompt from '#/components/Prompt'
 import {Text as NewText} from '#/components/Typography'
 
+const MAX_IMAGES = 4
+
 type CancelRef = {
   onPressCancel: () => void
 }
 
 type Props = ComposerOpts
-export const ComposePost = observer(function ComposePost({
+export const ComposePost = ({
   replyTo,
   onPost,
   quote: initQuote,
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
   cancelRef,
 }: Props & {
   cancelRef?: React.RefObject<CancelRef>
-}) {
+}) => {
   const {currentAccount} = useSession()
   const agent = useAgent()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
     )
   const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
 
-  const gallery = useMemo(
-    () => new GalleryModel(initImageUris),
-    [initImageUris],
+  const [images, setImages] = useState<ComposerImage[]>(() =>
+    createInitialImages(initImageUris),
   )
   const onClose = useCallback(() => {
     closeComposer()
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
   const onPressCancel = useCallback(() => {
     if (
       graphemeLength > 0 ||
-      !gallery.isEmpty ||
+      images.length !== 0 ||
       extGif ||
       videoUploadState.status !== 'idle'
     ) {
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
   }, [
     extGif,
     graphemeLength,
-    gallery.isEmpty,
+    images.length,
     closeAllDialogs,
     discardPromptControl,
     onClose,
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
     [extLink, setExtLink],
   )
 
+  const onImageAdd = useCallback(
+    (next: ComposerImage[]) => {
+      setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
+    },
+    [setImages],
+  )
+
   const onPhotoPasted = useCallback(
     async (uri: string) => {
       track('Composer:PastedPhotos')
       if (uri.startsWith('data:video/')) {
         selectVideo({uri, type: 'video', height: 0, width: 0})
       } else {
-        await gallery.paste(uri)
+        const res = await pasteImage(uri)
+        onImageAdd([res])
       }
     },
-    [gallery, track, selectVideo],
+    [track, selectVideo, onImageAdd],
   )
 
   const isAltTextRequiredAndMissing = useMemo(() => {
     if (!requireAltTextEnabled) return false
 
-    if (gallery.needsAltText) return true
+    if (images.some(img => img.alt === '')) return true
+
     if (extGif) {
       if (!extLink?.meta?.description) return true
 
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
       if (!parsedAlt.isPreferred) return true
     }
     return false
-  }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
+  }, [images, extLink, extGif, requireAltTextEnabled])
 
   const onPressPublish = React.useCallback(
     async (finishedUploading?: boolean) => {
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
 
       if (
         richtext.text.trim().length === 0 &&
-        gallery.isEmpty &&
+        images.length === 0 &&
         !extLink &&
         !quote &&
         videoUploadState.status === 'idle'
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
           await apilib.post(agent, {
             rawText: richtext.text,
             replyTo: replyTo?.uri,
-            images: gallery.images,
+            images,
             quote,
             extLink,
             labels,
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
       } catch (e: any) {
         logger.error(e, {
           message: `Composer: create post failed`,
-          hasImages: gallery.size > 0,
+          hasImages: images.length > 0,
         })
 
         if (extLink) {
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
       } finally {
         if (postUri) {
           logEvent('post:create', {
-            imageCount: gallery.size,
+            imageCount: images.length,
             isReply: replyTo != null,
             hasLink: extLink != null,
             hasQuote: quote != null,
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
           })
         }
         track('Create Post', {
-          imageCount: gallery.size,
+          imageCount: images.length,
         })
         if (replyTo && replyTo.uri) track('Post:Reply')
       }
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
       agent,
       captions,
       extLink,
-      gallery.images,
-      gallery.isEmpty,
-      gallery.size,
+      images,
       graphemeLength,
       isAltTextRequiredAndMissing,
       isProcessing,
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
     : _(msg`What's up?`)
 
   const canSelectImages =
-    gallery.size < 4 &&
+    images.length < MAX_IMAGES &&
     !extLink &&
     videoUploadState.status === 'idle' &&
     !videoUploadState.video
   const hasMedia =
-    gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
+    images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
 
   const onEmojiButtonPress = useCallback(() => {
     openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
             />
           </View>
 
-          <Gallery gallery={gallery} />
-          {gallery.isEmpty && extLink && (
+          <Gallery images={images} onChange={setImages} />
+          {images.length === 0 && extLink && (
             <View style={a.relative}>
               <ExternalEmbed
                 link={extLink}
@@ -801,13 +808,17 @@ export const ComposePost = observer(function ComposePost({
             <VideoUploadToolbar state={videoUploadState} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
+              <SelectPhotoBtn
+                size={images.length}
+                disabled={!canSelectImages}
+                onAdd={onImageAdd}
+              />
               <SelectVideoBtn
                 onSelectVideo={selectVideo}
                 disabled={!canSelectImages}
                 setError={setError}
               />
-              <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
+              <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
               <SelectGifBtn
                 onClose={focusTextInput}
                 onSelectGif={onSelectGif}
@@ -842,7 +853,7 @@ export const ComposePost = observer(function ComposePost({
       />
     </KeyboardAvoidingView>
   )
-})
+}
 
 export function useComposerCancelRef() {
   return useRef<CancelRef>(null)
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 4801ca0ab..f61d410df 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
         title: link.meta?.title ?? link.uri,
         uri: link.uri,
         description: link.meta?.description ?? '',
-        thumb: link.localThumb?.path,
+        thumb: link.localThumb?.source.path,
       },
     [link],
   )
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index a37452604..a05607c76 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -43,7 +43,7 @@ export function GifAltText({
         title: linkProp.meta?.title ?? linkProp.uri,
         uri: linkProp.uri,
         description: linkProp.meta?.description ?? '',
-        thumb: linkProp.localThumb?.path,
+        thumb: linkProp.localThumb?.source.path,
       },
       params: parseEmbedPlayerFromUrl(linkProp.uri),
     }
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 422a4dd93..775413e81 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,29 +1,36 @@
-import React, {useState} from 'react'
-import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import React from 'react'
+import {
+  ImageStyle,
+  Keyboard,
+  LayoutChangeEvent,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
 import {Image} from 'expo-image'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
 
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {Dimensions} from '#/lib/media/types'
 import {colors, s} from '#/lib/styles'
 import {isNative} from '#/platform/detection'
+import {ComposerImage, cropImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
 import {Text} from '#/view/com/util/text/Text'
 import {useTheme} from '#/alf'
 
 const IMAGE_GAP = 8
 
 interface GalleryProps {
-  gallery: GalleryModel
+  images: ComposerImage[]
+  onChange: (next: ComposerImage[]) => void
 }
 
-export const Gallery = (props: GalleryProps) => {
-  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
+export let Gallery = (props: GalleryProps): React.ReactNode => {
+  const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
 
   const onLayout = (evt: LayoutChangeEvent) => {
     const {width, height} = evt.nativeEvent.layout
@@ -41,177 +48,190 @@ export const Gallery = (props: GalleryProps) => {
     </View>
   )
 }
+Gallery = React.memo(Gallery)
 
 interface GalleryInnerProps extends GalleryProps {
   containerInfo: Dimensions
 }
 
-const GalleryInner = observer(function GalleryImpl({
-  gallery,
-  containerInfo,
-}: GalleryInnerProps) {
-  const {_} = useLingui()
+const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
   const {isMobile} = useWebMediaQueries()
-  const {openModal} = useModalControls()
-  const t = useTheme()
 
-  let side: number
+  const {altTextControlStyle, imageControlsStyle, imageStyle} =
+    React.useMemo(() => {
+      const side =
+        images.length === 1
+          ? 250
+          : (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
+            images.length
 
-  if (gallery.size === 1) {
-    side = 250
-  } else {
-    side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
-  }
+      const isOverflow = isMobile && images.length > 2
 
-  const imageStyle = {
-    height: side,
-    width: side,
-  }
-
-  const isOverflow = isMobile && gallery.size > 2
-
-  const altTextControlStyle = isOverflow
-    ? {
-        left: 4,
-        bottom: 4,
-      }
-    : !isMobile && gallery.size < 3
-    ? {
-        left: 8,
-        top: 8,
-      }
-    : {
-        left: 4,
-        top: 4,
+      return {
+        altTextControlStyle: isOverflow
+          ? {left: 4, bottom: 4}
+          : !isMobile && images.length < 3
+          ? {left: 8, top: 8}
+          : {left: 4, top: 4},
+        imageControlsStyle: {
+          display: 'flex' as const,
+          flexDirection: 'row' as const,
+          position: 'absolute' as const,
+          ...(isOverflow
+            ? {top: 4, right: 4, gap: 4}
+            : !isMobile && images.length < 3
+            ? {top: 8, right: 8, gap: 8}
+            : {top: 4, right: 4, gap: 4}),
+          zIndex: 1,
+        },
+        imageStyle: {
+          height: side,
+          width: side,
+        },
       }
+    }, [images.length, containerInfo, isMobile])
 
-  const imageControlsStyle = {
-    display: 'flex' as const,
-    flexDirection: 'row' as const,
-    position: 'absolute' as const,
-    ...(isOverflow
-      ? {
-          top: 4,
-          right: 4,
-          gap: 4,
-        }
-      : !isMobile && gallery.size < 3
-      ? {
-          top: 8,
-          right: 8,
-          gap: 8,
-        }
-      : {
-          top: 4,
-          right: 4,
-          gap: 4,
-        }),
-    zIndex: 1,
-  }
-
-  return !gallery.isEmpty ? (
+  return images.length !== 0 ? (
     <>
       <View testID="selectedPhotosView" style={styles.gallery}>
-        {gallery.images.map(image => (
-          <View key={`selected-image-${image.path}`} style={[imageStyle]}>
-            <TouchableOpacity
-              testID="altTextButton"
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Add alt text`)}
-              accessibilityHint=""
-              onPress={() => {
-                Keyboard.dismiss()
-                openModal({
-                  name: 'alt-text-image',
-                  image,
-                })
-              }}
-              style={[styles.altTextControl, altTextControlStyle]}>
-              {image.altText.length > 0 ? (
-                <FontAwesomeIcon
-                  icon="check"
-                  size={10}
-                  style={{color: t.palette.white}}
-                />
-              ) : (
-                <FontAwesomeIcon
-                  icon="plus"
-                  size={10}
-                  style={{color: t.palette.white}}
-                />
-              )}
-              <Text style={styles.altTextControlLabel} accessible={false}>
-                <Trans>ALT</Trans>
-              </Text>
-            </TouchableOpacity>
-            <View style={imageControlsStyle}>
-              <TouchableOpacity
-                testID="editPhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Edit image`)}
-                accessibilityHint=""
-                onPress={() => {
-                  if (isNative) {
-                    gallery.crop(image)
-                  } else {
-                    openModal({
-                      name: 'edit-image',
-                      image,
-                      gallery,
-                    })
-                  }
-                }}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="pen"
-                  size={12}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-              <TouchableOpacity
-                testID="removePhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Remove image`)}
-                accessibilityHint=""
-                onPress={() => gallery.remove(image)}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="xmark"
-                  size={16}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-            </View>
-            <TouchableOpacity
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Add alt text`)}
-              accessibilityHint=""
-              onPress={() => {
-                Keyboard.dismiss()
-                openModal({
-                  name: 'alt-text-image',
-                  image,
-                })
+        {images.map((image, index) => {
+          return (
+            <GalleryItem
+              key={image.source.id}
+              image={image}
+              altTextControlStyle={altTextControlStyle}
+              imageControlsStyle={imageControlsStyle}
+              imageStyle={imageStyle}
+              onChange={next => {
+                onChange(
+                  images.map(i => (i.source === image.source ? next : i)),
+                )
               }}
-              style={styles.altTextHiddenRegion}
-            />
+              onRemove={() => {
+                const next = images.slice()
+                next.splice(index, 1)
 
-            <Image
-              testID="selectedPhotoImage"
-              style={[styles.image, imageStyle] as ImageStyle}
-              source={{
-                uri: image.cropped?.path ?? image.path,
+                onChange(next)
               }}
-              accessible={true}
-              accessibilityIgnoresInvertColors
             />
-          </View>
-        ))}
+          )
+        })}
       </View>
       <AltTextReminder />
     </>
   ) : null
-})
+}
+
+type GalleryItemProps = {
+  image: ComposerImage
+  altTextControlStyle?: ViewStyle
+  imageControlsStyle?: ViewStyle
+  imageStyle?: ViewStyle
+  onChange: (next: ComposerImage) => void
+  onRemove: () => void
+}
+
+const GalleryItem = ({
+  image,
+  altTextControlStyle,
+  imageControlsStyle,
+  imageStyle,
+  onChange,
+  onRemove,
+}: GalleryItemProps): React.ReactNode => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {openModal} = useModalControls()
+
+  const onImageEdit = () => {
+    if (isNative) {
+      cropImage(image).then(next => {
+        onChange(next)
+      })
+    }
+  }
+
+  const onAltTextEdit = () => {
+    Keyboard.dismiss()
+    openModal({name: 'alt-text-image', image, onChange})
+  }
+
+  return (
+    <View style={imageStyle}>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        onPress={onAltTextEdit}
+        style={[styles.altTextControl, altTextControlStyle]}>
+        {image.alt.length !== 0 ? (
+          <FontAwesomeIcon
+            icon="check"
+            size={10}
+            style={{color: t.palette.white}}
+          />
+        ) : (
+          <FontAwesomeIcon
+            icon="plus"
+            size={10}
+            style={{color: t.palette.white}}
+          />
+        )}
+        <Text style={styles.altTextControlLabel} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+      <View style={imageControlsStyle}>
+        {isNative && (
+          <TouchableOpacity
+            testID="editPhotoButton"
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Edit image`)}
+            accessibilityHint=""
+            onPress={onImageEdit}
+            style={styles.imageControl}>
+            <FontAwesomeIcon
+              icon="pen"
+              size={12}
+              style={{color: colors.white}}
+            />
+          </TouchableOpacity>
+        )}
+        <TouchableOpacity
+          testID="removePhotoButton"
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Remove image`)}
+          accessibilityHint=""
+          onPress={onRemove}
+          style={styles.imageControl}>
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={{color: colors.white}}
+          />
+        </TouchableOpacity>
+      </View>
+      <TouchableOpacity
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        onPress={onAltTextEdit}
+        style={styles.altTextHiddenRegion}
+      />
+
+      <Image
+        testID="selectedPhotoImage"
+        style={[styles.image, imageStyle] as ImageStyle}
+        source={{
+          uri: (image.transformed ?? image.source).path,
+        }}
+        accessible={true}
+        accessibilityIgnoresInvertColors
+      />
+    </View>
+  )
+}
 
 export function AltTextReminder() {
   const t = useTheme()
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index f1f984103..2183ca790 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
 import {openCamera} from '#/lib/media/picker'
 import {logger} from '#/logger'
 import {isMobileWeb, isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
 
 type Props = {
-  gallery: GalleryModel
   disabled?: boolean
+  onAdd: (next: ComposerImage[]) => void
 }
 
-export function OpenCameraBtn({gallery, disabled}: Props) {
+export function OpenCameraBtn({disabled, onAdd}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
       if (mediaPermissionRes) {
         await MediaLibrary.createAssetAsync(img.path)
       }
-      gallery.add(img)
+
+      const res = await createComposerImage(img)
+
+      onAdd([res])
     } catch (err: any) {
       // ignore
       logger.warn('Error using camera', {error: err})
     }
   }, [
-    gallery,
+    onAdd,
     track,
     requestCameraAccessIfNeeded,
     mediaPermissionRes,
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 747653fc8..95d2df022 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
+import {openPicker} from '#/lib/media/picker'
 import {isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
 
 type Props = {
-  gallery: GalleryModel
+  size: number
   disabled?: boolean
+  onAdd: (next: ComposerImage[]) => void
 }
 
-export function SelectPhotoBtn({gallery, disabled}: Props) {
+export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
       return
     }
 
-    gallery.pick()
-  }, [track, requestPhotoAccessIfNeeded, gallery])
+    const images = await openPicker({
+      selectionLimit: 4 - size,
+      allowsMultipleSelection: true,
+    })
+
+    const results = await Promise.all(
+      images.map(img => createComposerImage(img)),
+    )
+
+    onAdd(results)
+  }, [track, requestPhotoAccessIfNeeded, size, onAdd])
 
   return (
     <Button
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 317514437..1a36b5034 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -3,6 +3,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {logger} from '#/logger'
+import {createComposerImage} from '#/state/gallery'
 import {useFetchDid} from '#/state/queries/handle'
 import {useGetPost} from '#/state/queries/post'
 import {useAgent} from '#/state/session'
@@ -26,7 +27,6 @@ import {
   isBskyStartUrl,
   isShortLink,
 } from 'lib/strings/url-helpers'
-import {ImageModel} from 'state/models/media/image'
 import {ComposerOpts} from 'state/shell/composer'
 
 export function useExternalLinkFetch({
@@ -161,14 +161,15 @@ export function useExternalLinkFetch({
         timeout: 15e3,
       })
         .catch(() => undefined)
-        .then(localThumb => {
+        .then(thumb => (thumb ? createComposerImage(thumb) : undefined))
+        .then(thumb => {
           if (aborted) {
             return
           }
           setExtLink({
             ...extLink,
             isLoading: false, // done
-            localThumb: localThumb ? new ImageModel(localThumb) : undefined,
+            localThumb: thumb,
           })
         })
       return cleanup