about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-12-14 15:35:15 -0600
committerPaul Frazee <pfrazee@gmail.com>2022-12-14 15:35:15 -0600
commit4966b2152eb213bac34cbcb0ff01c246b7746f5c (patch)
tree5cc90408631c984018f1b5a4b06f428d3d31d4a5 /src/view
parent345ec83f26e209929ca86b3885227e8508fb2cb8 (diff)
downloadvoidsky-4966b2152eb213bac34cbcb0ff01c246b7746f5c.tar.zst
Add post embeds (images and external links)
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/ComposePost.tsx56
-rw-r--r--src/view/com/composer/PhotoCarouselPicker.tsx35
-rw-r--r--src/view/com/composer/SelectedPhoto.tsx12
-rw-r--r--src/view/com/lightbox/Image.tsx25
-rw-r--r--src/view/com/lightbox/Lightbox.tsx7
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx4
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/PostEmbeds.tsx180
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx112
9 files changed, 339 insertions, 94 deletions
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index 7f4de654c..c6d371bc6 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -29,7 +29,6 @@ import {detectLinkables} from '../../../lib/strings'
 import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
 import {PhotoCarouselPicker} from './PhotoCarouselPicker'
 import {SelectedPhoto} from './SelectedPhoto'
-import {IMAGES_ENABLED} from '../../../build-flags'
 
 const MAX_TEXT_LENGTH = 256
 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
@@ -46,6 +45,7 @@ export const ComposePost = observer(function ComposePost({
   const store = useStores()
   const textInput = useRef<TextInput>(null)
   const [isProcessing, setIsProcessing] = useState(false)
+  const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
   const [text, setText] = useState('')
   const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
@@ -81,6 +81,10 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [])
 
+  const onSelectPhotos = (photos: string[]) => {
+    setSelectedPhotos(photos)
+  }
+
   const onChangeText = (newText: string) => {
     setText(newText)
 
@@ -109,15 +113,16 @@ export const ComposePost = observer(function ComposePost({
     }
     setIsProcessing(true)
     try {
-      const replyRef = replyTo
-        ? {uri: replyTo.uri, cid: replyTo.cid}
-        : undefined
-      await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
-    } catch (e: any) {
-      console.error(`Failed to create post: ${e.toString()}`)
-      setError(
-        'Post failed to upload. Please check your Internet connection and try again.',
+      await apilib.post(
+        store,
+        text,
+        replyTo?.uri,
+        selectedPhotos,
+        autocompleteView.knownHandles,
+        setProcessingState,
       )
+    } catch (e: any) {
+      setError(e.message)
       setIsProcessing(false)
       return
     }
@@ -189,6 +194,11 @@ export const ComposePost = observer(function ComposePost({
             </View>
           )}
         </View>
+        {isProcessing ? (
+          <View style={styles.processingLine}>
+            <Text>{processingState}</Text>
+          </View>
+        ) : undefined}
         {error !== '' && (
           <View style={styles.errorLine}>
             <View style={styles.errorIcon}>
@@ -198,7 +208,7 @@ export const ComposePost = observer(function ComposePost({
                 size={10}
               />
             </View>
-            <Text style={s.red4}>{error}</Text>
+            <Text style={[s.red4, s.flex1]}>{error}</Text>
           </View>
         )}
         {replyTo ? (
@@ -240,18 +250,15 @@ export const ComposePost = observer(function ComposePost({
         </View>
         <SelectedPhoto
           selectedPhotos={selectedPhotos}
-          setSelectedPhotos={setSelectedPhotos}
+          onSelectPhotos={onSelectPhotos}
         />
-        {IMAGES_ENABLED &&
-          localPhotos.photos != null &&
-          text === '' &&
-          selectedPhotos.length === 0 && (
-            <PhotoCarouselPicker
-              selectedPhotos={selectedPhotos}
-              setSelectedPhotos={setSelectedPhotos}
-              localPhotos={localPhotos}
-            />
-          )}
+        {localPhotos.photos != null && selectedPhotos.length < 4 && (
+          <PhotoCarouselPicker
+            selectedPhotos={selectedPhotos}
+            onSelectPhotos={onSelectPhotos}
+            localPhotos={localPhotos}
+          />
+        )}
         <View style={styles.bottomBar}>
           <View style={s.flex1} />
           <Text style={[s.mr10, {color: progressColor}]}>
@@ -322,6 +329,13 @@ const styles = StyleSheet.create({
     paddingHorizontal: 20,
     paddingVertical: 6,
   },
+  processingLine: {
+    backgroundColor: colors.gray1,
+    borderRadius: 6,
+    paddingHorizontal: 8,
+    paddingVertical: 6,
+    marginBottom: 6,
+  },
   errorLine: {
     flexDirection: 'row',
     backgroundColor: colors.red1,
diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/PhotoCarouselPicker.tsx
index f4af4c61e..7095e9dd1 100644
--- a/src/view/com/composer/PhotoCarouselPicker.tsx
+++ b/src/view/com/composer/PhotoCarouselPicker.tsx
@@ -8,48 +8,54 @@ import {
   openCropper,
 } from 'react-native-image-crop-picker'
 
+const IMAGE_PARAMS = {
+  width: 500,
+  height: 500,
+  freeStyleCropEnabled: true,
+  forceJpg: true, // ios only
+  compressImageQuality: 0.7,
+}
+
 export const PhotoCarouselPicker = ({
   selectedPhotos,
-  setSelectedPhotos,
+  onSelectPhotos,
   localPhotos,
 }: {
   selectedPhotos: string[]
-  setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
+  onSelectPhotos: (v: string[]) => void
   localPhotos: any
 }) => {
   const handleOpenCamera = useCallback(() => {
     openCamera({
       mediaType: 'photo',
       cropping: true,
-      width: 1000,
-      height: 1000,
+      ...IMAGE_PARAMS,
     }).then(
       item => {
-        setSelectedPhotos([item.path, ...selectedPhotos])
+        onSelectPhotos([item.path, ...selectedPhotos])
       },
       _err => {
         // ignore
       },
     )
-  }, [selectedPhotos, setSelectedPhotos])
+  }, [selectedPhotos, onSelectPhotos])
 
   const handleSelectPhoto = useCallback(
     async (uri: string) => {
       const img = await openCropper({
         mediaType: 'photo',
         path: uri,
-        width: 1000,
-        height: 1000,
+        ...IMAGE_PARAMS,
       })
-      setSelectedPhotos([img.path, ...selectedPhotos])
+      onSelectPhotos([img.path, ...selectedPhotos])
     },
-    [selectedPhotos, setSelectedPhotos],
+    [selectedPhotos, onSelectPhotos],
   )
 
   const handleOpenGallery = useCallback(() => {
     openPicker({
       multiple: true,
-      maxFiles: 4,
+      maxFiles: 4 - selectedPhotos.length,
       mediaType: 'photo',
     }).then(async items => {
       const result = []
@@ -58,14 +64,13 @@ export const PhotoCarouselPicker = ({
         const img = await openCropper({
           mediaType: 'photo',
           path: image.path,
-          width: 1000,
-          height: 1000,
+          ...IMAGE_PARAMS,
         })
         result.push(img.path)
       }
-      setSelectedPhotos([...result, ...selectedPhotos])
+      onSelectPhotos([...result, ...selectedPhotos])
     })
-  }, [selectedPhotos, setSelectedPhotos])
+  }, [selectedPhotos, onSelectPhotos])
 
   return (
     <ScrollView
diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx
index 88209b3df..7711415f6 100644
--- a/src/view/com/composer/SelectedPhoto.tsx
+++ b/src/view/com/composer/SelectedPhoto.tsx
@@ -5,10 +5,10 @@ import {colors} from '../../lib/styles'
 
 export const SelectedPhoto = ({
   selectedPhotos,
-  setSelectedPhotos,
+  onSelectPhotos,
 }: {
   selectedPhotos: string[]
-  setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
+  onSelectPhotos: (v: string[]) => void
 }) => {
   const imageStyle =
     selectedPhotos.length === 1
@@ -19,11 +19,9 @@ export const SelectedPhoto = ({
 
   const handleRemovePhoto = useCallback(
     item => {
-      setSelectedPhotos(
-        selectedPhotos.filter(filterItem => filterItem !== item),
-      )
+      onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
     },
-    [selectedPhotos, setSelectedPhotos],
+    [selectedPhotos, onSelectPhotos],
   )
 
   return selectedPhotos.length !== 0 ? (
@@ -57,8 +55,10 @@ const styles = StyleSheet.create({
     marginTop: 16,
   },
   image: {
+    resizeMode: 'contain',
     borderRadius: 8,
     margin: 2,
+    backgroundColor: colors.gray1,
   },
   image250: {
     width: 250,
diff --git a/src/view/com/lightbox/Image.tsx b/src/view/com/lightbox/Image.tsx
new file mode 100644
index 000000000..2e3307774
--- /dev/null
+++ b/src/view/com/lightbox/Image.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {Image, StyleSheet, useWindowDimensions, View} from 'react-native'
+
+export function Component({uri}: {uri: string}) {
+  const winDim = useWindowDimensions()
+  const top = winDim.height / 2 - (winDim.width - 40) / 2 - 100
+  console.log(uri)
+  return (
+    <View style={[styles.container, {top}]}>
+      <Image style={styles.image} source={{uri}} />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    position: 'absolute',
+    left: 0,
+  },
+  image: {
+    resizeMode: 'contain',
+    width: '100%',
+    aspectRatio: 1,
+  },
+})
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 9432f0151..198f20391 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -7,6 +7,7 @@ import {useStores} from '../../../state'
 import * as models from '../../../state/models/shell-ui'
 
 import * as ProfileImageLightbox from './ProfileImage'
+import * as ImageLightbox from './Image'
 
 export const Lightbox = observer(function Lightbox() {
   const store = useStores()
@@ -26,6 +27,12 @@ export const Lightbox = observer(function Lightbox() {
         {...(store.shell.activeLightbox as models.ProfileImageLightbox)}
       />
     )
+  } else if (store.shell.activeLightbox?.name === 'image') {
+    element = (
+      <ImageLightbox.Component
+        {...(store.shell.activeLightbox as models.ImageLightbox)}
+      />
+    )
   } else {
     return <View />
   }
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 8408eb6c0..ae13e0765 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -167,7 +167,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 style={[styles.postText, styles.postTextLarge]}
               />
             </View>
-            <PostEmbeds entities={record.entities} style={s.mb10} />
+            <PostEmbeds embed={item.embed} style={s.mb10} />
             {item._isHighlightedPost && hasEngagement ? (
               <View style={styles.expandedInfo}>
                 {item.repostCount ? (
@@ -277,7 +277,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 style={[styles.postText]}
               />
             </View>
-            <PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
+            <PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
             <PostCtrls
               replyCount={item.replyCount}
               repostCount={item.repostCount}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 761b3c902..2feb71a98 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -198,7 +198,7 @@ export const FeedItem = observer(function FeedItem({
               style={styles.postText}
             />
           </View>
-          <PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
+          <PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
           <PostCtrls
             replyCount={item.replyCount}
             repostCount={item.repostCount}
diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx
index 1591c658a..ea15dc9ca 100644
--- a/src/view/com/util/PostEmbeds.tsx
+++ b/src/view/com/util/PostEmbeds.tsx
@@ -1,88 +1,170 @@
 import React, {useEffect, useState} from 'react'
 import {
   ActivityIndicator,
+  Image,
+  ImageStyle,
   StyleSheet,
   StyleProp,
   Text,
+  TouchableWithoutFeedback,
   View,
   ViewStyle,
 } from 'react-native'
-import {Entity} from '../../../third-party/api/src/client/types/app/bsky/feed/post'
+import {
+  Record as PostRecord,
+  Entity,
+} from '../../../third-party/api/src/client/types/app/bsky/feed/post'
+import * as AppBskyEmbedImages from '../../../third-party/api/src/client/types/app/bsky/embed/images'
+import * as AppBskyEmbedExternal from '../../../third-party/api/src/client/types/app/bsky/embed/external'
 import {Link} from '../util/Link'
 import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta'
 import {colors} from '../../lib/styles'
-import {useStores} from '../../../state'
+import {AutoSizedImage} from './images/AutoSizedImage'
+
+type Embed =
+  | AppBskyEmbedImages.Presented
+  | AppBskyEmbedExternal.Presented
+  | {$type: string; [k: string]: unknown}
 
 export function PostEmbeds({
-  entities,
+  embed,
   style,
 }: {
-  entities?: Entity[]
+  embed?: Embed
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
-  const [linkMeta, setLinkMeta] = useState<LinkMeta | undefined>(undefined)
-  const link = entities?.find(
-    ent =>
-      ent.type === 'link' && getLikelyType(ent.value || '') === LikelyType.HTML,
-  )
-
-  useEffect(() => {
-    let aborted = false
-    store.linkMetas.getLinkMeta(link?.value || '').then(linkMeta => {
-      if (!aborted) {
-        setLinkMeta(linkMeta)
+  if (embed?.$type === 'app.bsky.embed.images#presented') {
+    const imgEmbed = embed as AppBskyEmbedImages.Presented
+    if (imgEmbed.images.length > 0) {
+      const Thumb = ({i, style}: {i: number; style: StyleProp<ImageStyle>}) => (
+        <AutoSizedImage
+          style={style}
+          uri={imgEmbed.images[i].thumb}
+          fullSizeUri={imgEmbed.images[i].fullsize}
+        />
+      )
+      if (imgEmbed.images.length === 4) {
+        return (
+          <View style={styles.imagesContainer}>
+            <View style={styles.imagePair}>
+              <Thumb i={0} style={styles.imagePairItem} />
+              <View style={styles.imagesWidthSpacer} />
+              <Thumb i={1} style={styles.imagePairItem} />
+            </View>
+            <View style={styles.imagesHeightSpacer} />
+            <View style={styles.imagePair}>
+              <Thumb i={2} style={styles.imagePairItem} />
+              <View style={styles.imagesWidthSpacer} />
+              <Thumb i={3} style={styles.imagePairItem} />
+            </View>
+          </View>
+        )
+      } else if (imgEmbed.images.length === 3) {
+        return (
+          <View style={styles.imagesContainer}>
+            <View style={styles.imageWide}>
+              <Thumb i={0} style={styles.imageWideItem} />
+            </View>
+            <View style={styles.imagesHeightSpacer} />
+            <View style={styles.imagePair}>
+              <Thumb i={1} style={styles.imagePairItem} />
+              <View style={styles.imagesWidthSpacer} />
+              <Thumb i={2} style={styles.imagePairItem} />
+            </View>
+          </View>
+        )
+      } else if (imgEmbed.images.length === 2) {
+        return (
+          <View style={styles.imagesContainer}>
+            <View style={styles.imagePair}>
+              <Thumb i={0} style={styles.imagePairItem} />
+              <View style={styles.imagesWidthSpacer} />
+              <Thumb i={1} style={styles.imagePairItem} />
+            </View>
+          </View>
+        )
+      } else {
+        return (
+          <View style={styles.imagesContainer}>
+            <View style={styles.imageBig}>
+              <Thumb i={0} style={styles.imageBigItem} />
+            </View>
+          </View>
+        )
       }
-    })
-
-    return () => {
-      aborted = true
     }
-  }, [link])
-
-  if (!link) {
-    return <View />
   }
-
-  return (
-    <Link style={[styles.outer, style]} href={link.value}>
-      {linkMeta ? (
-        <>
-          <Text numberOfLines={1} style={styles.title}>
-            {linkMeta.title || linkMeta.url}
-          </Text>
-          <Text numberOfLines={1} style={styles.url}>
-            {linkMeta.url}
+  if (embed?.$type === 'app.bsky.embed.external#presented') {
+    const externalEmbed = embed as AppBskyEmbedExternal.Presented
+    const link = externalEmbed.external
+    return (
+      <Link style={[styles.extOuter, style]} href={link.uri}>
+        {link.thumb ? (
+          <AutoSizedImage style={style} uri={link.thumb} />
+        ) : undefined}
+        <Text numberOfLines={1} style={styles.extTitle}>
+          {link.title || link.uri}
+        </Text>
+        <Text numberOfLines={1} style={styles.extUrl}>
+          {link.uri}
+        </Text>
+        {link.description ? (
+          <Text numberOfLines={2} style={styles.extDescription}>
+            {link.description}
           </Text>
-          {linkMeta.description ? (
-            <Text numberOfLines={2} style={styles.description}>
-              {linkMeta.description}
-            </Text>
-          ) : undefined}
-        </>
-      ) : (
-        <ActivityIndicator />
-      )}
-    </Link>
-  )
+        ) : undefined}
+      </Link>
+    )
+  }
+  return <View />
 }
 
 const styles = StyleSheet.create({
-  outer: {
+  imagesContainer: {
+    marginBottom: 20,
+  },
+  imagesWidthSpacer: {
+    width: 5,
+  },
+  imagesHeightSpacer: {
+    height: 5,
+  },
+  imagePair: {
+    flexDirection: 'row',
+  },
+  imagePairItem: {
+    resizeMode: 'contain',
+    flex: 1,
+    borderRadius: 4,
+  },
+  imageWide: {},
+  imageWideItem: {
+    resizeMode: 'contain',
+    borderRadius: 4,
+  },
+  imageBig: {},
+  imageBigItem: {
+    borderRadius: 4,
+  },
+
+  extOuter: {
     borderWidth: 1,
     borderColor: colors.gray2,
     borderRadius: 8,
     padding: 10,
   },
-  title: {
+  extImage: {
+    // TODO
+  },
+  extTitle: {
     fontSize: 16,
     fontWeight: 'bold',
   },
-  description: {
+  extDescription: {
     marginTop: 4,
     fontSize: 15,
   },
-  url: {
+  extUrl: {
     color: colors.gray4,
   },
 })
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
new file mode 100644
index 000000000..fedc94321
--- /dev/null
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -0,0 +1,112 @@
+import React, {useState, useEffect, useMemo} from 'react'
+import {
+  Image,
+  ImageStyle,
+  LayoutChangeEvent,
+  StyleProp,
+  StyleSheet,
+  Text,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import {ImageLightbox} from '../../../../state/models/shell-ui'
+import {useStores} from '../../../../state'
+import {colors} from '../../../lib/styles'
+
+const MAX_HEIGHT = 300
+
+interface Dim {
+  width: number
+  height: number
+}
+
+export function AutoSizedImage({
+  uri,
+  fullSizeUri,
+  style,
+}: {
+  uri: string
+  fullSizeUri?: string
+  style: StyleProp<ImageStyle>
+}) {
+  const store = useStores()
+  const [error, setError] = useState<string | undefined>()
+  const [imgInfo, setImgInfo] = useState<Dim | undefined>()
+  const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
+  const calculatedStyle = useMemo(() => {
+    if (imgInfo && containerInfo) {
+      // imgInfo.height / imgInfo.width = x / containerInfo.width
+      // x = imgInfo.height / imgInfo.width * containerInfo.width
+      return {
+        height: Math.min(
+          MAX_HEIGHT,
+          (imgInfo.height / imgInfo.width) * containerInfo.width,
+        ),
+      }
+    }
+    return undefined
+  }, [imgInfo, containerInfo])
+
+  useEffect(() => {
+    Image.getSize(
+      uri,
+      (width: number, height: number) => {
+        setImgInfo({width, height})
+      },
+      (error: any) => {
+        setError(String(error))
+      },
+    )
+  }, [uri])
+
+  const onLayout = (evt: LayoutChangeEvent) => {
+    setContainerInfo({
+      width: evt.nativeEvent.layout.width,
+      height: evt.nativeEvent.layout.height,
+    })
+  }
+
+  const onPressImage = () => {
+    if (fullSizeUri) {
+      store.shell.openLightbox(new ImageLightbox(fullSizeUri))
+    }
+  }
+
+  return (
+    <View style={style}>
+      <TouchableWithoutFeedback onPress={onPressImage}>
+        {error ? (
+          <View style={[styles.container, styles.errorContainer]}>
+            <Text style={styles.error}>{error}</Text>
+          </View>
+        ) : calculatedStyle ? (
+          <View style={styles.container}>
+            <Image style={calculatedStyle} source={{uri}} />
+          </View>
+        ) : (
+          <View style={[style, styles.placeholder]} onLayout={onLayout} />
+        )}
+      </TouchableWithoutFeedback>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  placeholder: {
+    width: '100%',
+    aspectRatio: 1,
+    backgroundColor: colors.gray1,
+  },
+  errorContainer: {
+    backgroundColor: colors.red1,
+    paddingHorizontal: 8,
+    paddingVertical: 4,
+  },
+  container: {
+    borderRadius: 8,
+    overflow: 'hidden',
+  },
+  error: {
+    color: colors.red5,
+  },
+})