about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__mocks__/react-native-fs.js1
-rw-r--r--__mocks__/state-mock.ts1
-rw-r--r--__tests__/lib/images.test.ts2
-rw-r--r--__tests__/view/com/composer/ComposePost.test.tsx1
-rw-r--r--src/lib/strings.ts2
-rw-r--r--src/state/lib/api.ts110
-rw-r--r--src/view/com/composer/ComposePost.tsx82
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx125
-rw-r--r--src/view/com/util/PostEmbeds.tsx2
9 files changed, 262 insertions, 64 deletions
diff --git a/__mocks__/react-native-fs.js b/__mocks__/react-native-fs.js
new file mode 100644
index 000000000..b1c6ea436
--- /dev/null
+++ b/__mocks__/react-native-fs.js
@@ -0,0 +1 @@
+export default {}
diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts
index b26e62510..129f9c859 100644
--- a/__mocks__/state-mock.ts
+++ b/__mocks__/state-mock.ts
@@ -311,6 +311,7 @@ export const mockedFeedStore = {
   loadLatest: jest.fn(),
   update: jest.fn(),
   checkForLatest: jest.fn().mockRejectedValue('Error checking for latest'),
+  registerListeners: jest.fn().mockReturnValue(jest.fn()),
   // unknown required because of the missing private methods: _xLoading, _xIdle, _pendingWork, _initialLoad, _loadLatest, _loadMore, _update, _replaceAll, _appendAll, _prependAll, _updateAll, _getFeed, loadMoreCursor, pollCursor, _loadPromise, _updatePromise, _loadLatestPromise, _loadMorePromise
 } as unknown as FeedModel
 
diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts
index d53a5bc05..952b0ca48 100644
--- a/__tests__/lib/images.test.ts
+++ b/__tests__/lib/images.test.ts
@@ -54,7 +54,7 @@ describe('downloadAndResize', () => {
       100,
       100,
       'JPEG',
-      1,
+      100,
       undefined,
       undefined,
       undefined,
diff --git a/__tests__/view/com/composer/ComposePost.test.tsx b/__tests__/view/com/composer/ComposePost.test.tsx
index 84377f62f..5c5a61812 100644
--- a/__tests__/view/com/composer/ComposePost.test.tsx
+++ b/__tests__/view/com/composer/ComposePost.test.tsx
@@ -63,6 +63,7 @@ describe('ComposePost', () => {
       mockedRootStore,
       'testing publish',
       'testUri',
+      undefined,
       [],
       new Set<string>(),
       expect.anything(),
diff --git a/src/lib/strings.ts b/src/lib/strings.ts
index 77d8298ac..04d8656f7 100644
--- a/src/lib/strings.ts
+++ b/src/lib/strings.ts
@@ -96,7 +96,7 @@ export function extractEntities(
   {
     // links
     const re =
-      /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gm
+      /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
     while ((match = re.exec(text))) {
       let value = match[2]
       if (!value.startsWith('http')) {
diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts
index fd020aeee..1dfbf5090 100644
--- a/src/state/lib/api.ts
+++ b/src/state/lib/api.ts
@@ -15,7 +15,13 @@ import {RootStoreModel} from '../models/root-store'
 import {extractEntities} from '../../lib/strings'
 import {isNetworkError} from '../../lib/errors'
 import {downloadAndResize} from '../../lib/images'
-import {getLikelyType, LikelyType, getLinkMeta} from '../../lib/link-meta'
+import {
+  getLikelyType,
+  LikelyType,
+  getLinkMeta,
+  LinkMeta,
+} from '../../lib/link-meta'
+import {Image} from '../../lib/images'
 
 const TIMEOUT = 10e3 // 10s
 
@@ -23,10 +29,18 @@ export function doPolyfill() {
   AtpApi.xrpc.fetch = fetchHandler
 }
 
+export interface ExternalEmbedDraft {
+  uri: string
+  isLoading: boolean
+  meta?: LinkMeta
+  localThumb?: Image
+}
+
 export async function post(
   store: RootStoreModel,
   text: string,
   replyTo?: string,
+  extLink?: ExternalEmbedDraft,
   images?: string[],
   knownHandles?: Set<string>,
   onStateChange?: (state: string) => void,
@@ -67,68 +81,44 @@ export async function post(
     }
   }
 
-  if (!embed && entities) {
-    const link = entities.find(
-      ent =>
-        ent.type === 'link' &&
-        getLikelyType(ent.value || '') === LikelyType.HTML,
-    )
-    if (link) {
-      try {
-        onStateChange?.(`Fetching link metadata...`)
-        let thumb
-        const linkMeta = await getLinkMeta(link.value)
-        if (linkMeta.image) {
-          onStateChange?.(`Downloading link thumbnail...`)
-          const thumbLocal = await downloadAndResize({
-            uri: linkMeta.image,
-            width: 250,
-            height: 250,
-            mode: 'contain',
-            maxSize: 100000,
-            timeout: 15e3,
-          }).catch(() => undefined)
-          if (thumbLocal) {
-            onStateChange?.(`Uploading link thumbnail...`)
-            let encoding
-            if (thumbLocal.uri.endsWith('.png')) {
-              encoding = 'image/png'
-            } else if (
-              thumbLocal.uri.endsWith('.jpeg') ||
-              thumbLocal.uri.endsWith('.jpg')
-            ) {
-              encoding = 'image/jpeg'
-            } else {
-              store.log.warn(
-                'Unexpected image format for thumbnail, skipping',
-                thumbLocal.uri,
-              )
-            }
-            if (encoding) {
-              const thumbUploadRes = await store.api.com.atproto.blob.upload(
-                thumbLocal.uri, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
-                {encoding},
-              )
-              thumb = {
-                cid: thumbUploadRes.data.cid,
-                mimeType: encoding,
-              }
-            }
-          }
+  if (!embed && extLink) {
+    let thumb
+    if (extLink.localThumb) {
+      onStateChange?.(`Uploading link thumbnail...`)
+      let encoding
+      if (extLink.localThumb.path.endsWith('.png')) {
+        encoding = 'image/png'
+      } else if (
+        extLink.localThumb.path.endsWith('.jpeg') ||
+        extLink.localThumb.path.endsWith('.jpg')
+      ) {
+        encoding = 'image/jpeg'
+      } else {
+        store.log.warn(
+          'Unexpected image format for thumbnail, skipping',
+          extLink.localThumb.path,
+        )
+      }
+      if (encoding) {
+        const thumbUploadRes = await store.api.com.atproto.blob.upload(
+          extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
+          {encoding},
+        )
+        thumb = {
+          cid: thumbUploadRes.data.cid,
+          mimeType: encoding,
         }
-        embed = {
-          $type: 'app.bsky.embed.external',
-          external: {
-            uri: link.value,
-            title: linkMeta.title || linkMeta.url,
-            description: linkMeta.description || '',
-            thumb,
-          },
-        } as AppBskyEmbedExternal.Main
-      } catch (e: any) {
-        store.log.warn(`Failed to fetch link meta for ${link.value}`, e)
       }
     }
+    embed = {
+      $type: 'app.bsky.embed.external',
+      external: {
+        uri: extLink.uri,
+        title: extLink.meta?.title || '',
+        description: extLink.meta?.description || '',
+        thumb,
+      },
+    } as AppBskyEmbedExternal.Main
   }
 
   if (replyTo) {
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index abdcd04ec..6a959d41e 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -16,6 +16,7 @@ import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
 import {Autocomplete} from './Autocomplete'
+import {ExternalEmbed} from './ExternalEmbed'
 import {Text} from '../util/text/Text'
 import * as Toast from '../util/Toast'
 // @ts-ignore no type definition -prf
@@ -28,7 +29,9 @@ import {useStores} from '../../../state'
 import * as apilib from '../../../state/lib/api'
 import {ComposerOpts} from '../../../state/models/shell-ui'
 import {s, colors, gradients} from '../../lib/styles'
-import {detectLinkables} from '../../../lib/strings'
+import {detectLinkables, extractEntities} from '../../../lib/strings'
+import {getLinkMeta} from '../../../lib/link-meta'
+import {downloadAndResize} from '../../../lib/images'
 import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
 import {PhotoCarouselPicker} from './PhotoCarouselPicker'
 import {SelectedPhoto} from './SelectedPhoto'
@@ -56,6 +59,10 @@ export const ComposePost = observer(function ComposePost({
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
   const [text, setText] = useState('')
+  const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
+    undefined,
+  )
+  const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([])
   const [isSelectingPhotos, setIsSelectingPhotos] = useState(
     imagesOpen || false,
   )
@@ -71,11 +78,61 @@ export const ComposePost = observer(function ComposePost({
     [store],
   )
 
+  // initial setup
   useEffect(() => {
     autocompleteView.setup()
     localPhotos.setup()
   }, [autocompleteView, localPhotos])
 
+  // external link metadata-fetch flow
+  useEffect(() => {
+    let aborted = false
+    const cleanup = () => {
+      aborted = true
+    }
+    if (!extLink) {
+      return cleanup
+    }
+    if (!extLink.meta) {
+      getLinkMeta(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: 250,
+        height: 250,
+        mode: 'contain',
+        maxSize: 100000,
+        timeout: 15e3,
+      })
+        .catch(() => undefined)
+        .then(localThumb => {
+          setExtLink({
+            ...extLink,
+            isLoading: false, // done
+            localThumb,
+          })
+        })
+      return cleanup
+    }
+    if (extLink.isLoading) {
+      setExtLink({
+        ...extLink,
+        isLoading: false, // done
+      })
+    }
+  }, [extLink])
+
   useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
@@ -119,6 +176,22 @@ export const ComposePost = observer(function ComposePost({
     } else {
       autocompleteView.setActive(false)
     }
+
+    if (!extLink && /\s$/.test(newText)) {
+      const ents = extractEntities(newText)
+      const entLink = ents
+        ?.filter(
+          ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value),
+        )
+        .pop() // use last
+      if (entLink) {
+        setExtLink({
+          uri: entLink.value,
+          isLoading: true,
+        })
+        setAttemptedExtLinks([...attemptedExtLinks, entLink.value])
+      }
+    }
   }
   const onPressCancel = () => {
     onClose()
@@ -141,6 +214,7 @@ export const ComposePost = observer(function ComposePost({
         store,
         text,
         replyTo?.uri,
+        extLink,
         selectedPhotos,
         autocompleteView.knownHandles,
         setProcessingState,
@@ -297,6 +371,12 @@ export const ComposePost = observer(function ComposePost({
               selectedPhotos={selectedPhotos}
               onSelectPhotos={onSelectPhotos}
             />
+            {!selectedPhotos.length && extLink && (
+              <ExternalEmbed
+                link={extLink}
+                onRemove={() => setExtLink(undefined)}
+              />
+            )}
           </ScrollView>
           {isSelectingPhotos &&
             localPhotos.photos != null &&
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
new file mode 100644
index 000000000..7eaec5f04
--- /dev/null
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -0,0 +1,125 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {BlurView} from '@react-native-community/blur'
+import LinearGradient from 'react-native-linear-gradient'
+import {AutoSizedImage} from '../util/images/AutoSizedImage'
+import {Text} from '../util/text/Text'
+import {s, gradients} from '../../lib/styles'
+import {usePalette} from '../../lib/hooks/usePalette'
+import {ExternalEmbedDraft} from '../../../state/lib/api'
+
+export const ExternalEmbed = ({
+  link,
+  onRemove,
+}: {
+  link?: ExternalEmbedDraft
+  onRemove: () => void
+}) => {
+  const pal = usePalette('default')
+  const palError = usePalette('error')
+  if (!link) {
+    return <View />
+  }
+  return (
+    <View style={[styles.outer, pal.view, pal.border]}>
+      {link.isLoading ? (
+        <View
+          style={[
+            styles.image,
+            styles.imageFallback,
+            {backgroundColor: pal.colors.backgroundLight},
+          ]}>
+          <ActivityIndicator size="large" style={styles.spinner} />
+        </View>
+      ) : link.localThumb ? (
+        <AutoSizedImage
+          uri={link.localThumb.path}
+          containerStyle={styles.image}
+        />
+      ) : (
+        <LinearGradient
+          colors={[gradients.blueDark.start, gradients.blueDark.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.image, styles.imageFallback]}
+        />
+      )}
+      <TouchableWithoutFeedback onPress={onRemove}>
+        <BlurView style={styles.removeBtn} blurType="dark">
+          <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
+        </BlurView>
+      </TouchableWithoutFeedback>
+      <View style={styles.inner}>
+        {!!link.meta?.title && (
+          <Text type="sm-bold" numberOfLines={2} style={[pal.text]}>
+            {link.meta.title}
+          </Text>
+        )}
+        <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.uri]}>
+          {link.uri}
+        </Text>
+        {!!link.meta?.description && (
+          <Text
+            type="sm"
+            numberOfLines={2}
+            style={[pal.text, styles.description]}>
+            {link.meta.description}
+          </Text>
+        )}
+        {!!link.meta?.error && (
+          <Text
+            type="sm"
+            numberOfLines={2}
+            style={[{color: palError.colors.background}, styles.description]}>
+            {link.meta.error}
+          </Text>
+        )}
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    borderWidth: 1,
+    borderRadius: 8,
+    marginTop: 20,
+  },
+  inner: {
+    padding: 10,
+  },
+  image: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+    width: '100%',
+    height: 200,
+  },
+  imageFallback: {
+    height: 160,
+  },
+  removeBtn: {
+    position: 'absolute',
+    top: 10,
+    right: 10,
+    width: 36,
+    height: 36,
+    borderRadius: 18,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  spinner: {
+    marginTop: 60,
+  },
+  uri: {
+    marginTop: 2,
+  },
+  description: {
+    marginTop: 4,
+  },
+})
diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx
index bb98f55db..3fb93ed48 100644
--- a/src/view/com/util/PostEmbeds.tsx
+++ b/src/view/com/util/PostEmbeds.tsx
@@ -132,7 +132,7 @@ const styles = StyleSheet.create({
     borderTopLeftRadius: 6,
     borderTopRightRadius: 6,
     width: '100%',
-    height: 200,
+    maxHeight: 200,
   },
   extImageFallback: {
     height: 160,