about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorJoão Ferreiro <joaoferreiro5@gmail.com>2022-12-02 16:41:01 +0000
committerGitHub <noreply@github.com>2022-12-02 10:41:01 -0600
commit67c4dcff3731444c6e6eadcbed1d55bd503bda4a (patch)
treefc15ad58ca5694135851e189faf8a389ff5c933d /src
parent7ae1bac6208c6c00363aae344c76261a28433614 (diff)
downloadvoidsky-67c4dcff3731444c6e6eadcbed1d55bd503bda4a.tar.zst
Upload image in composer (#27)
* upload images in composer v1

* fix android compile

* reafctor image carousel into new component;
fix photo overlapping text in composer

* revert android changes

* further refactoring code into different components

* move show carousel out of the component

* fixing add photo using camera

* fix typescript issue; force mediatype photo

* change post test with photo attached;
remove auto linking settings

* use runInAction in getPhotos model

* react-hooks/exhaustive-deps fixes

* crop every photo;
make use of useCallback

* moving placeholder condition

* Cleanup

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/state/models/user-local-photos.ts27
-rw-r--r--src/view/com/composer/ComposePost.tsx65
-rw-r--r--src/view/com/composer/PhotoCarouselPicker.tsx128
-rw-r--r--src/view/com/composer/SelectedPhoto.tsx87
-rw-r--r--src/view/index.ts6
5 files changed, 304 insertions, 9 deletions
diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts
new file mode 100644
index 000000000..9a1455039
--- /dev/null
+++ b/src/state/models/user-local-photos.ts
@@ -0,0 +1,27 @@
+import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {CameraRoll} from '@react-native-camera-roll/camera-roll'
+import {RootStoreModel} from './root-store'
+
+export class UserLocalPhotosModel {
+  // state
+  photos: PhotoIdentifier[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {
+      rootStore: false,
+    })
+  }
+
+  async setup() {
+    await this._getPhotos()
+  }
+
+  private async _getPhotos() {
+    CameraRoll.getPhotos({first: 20}).then(r => {
+      runInAction(() => {
+        this.photos = r.edges
+      })
+    })
+  }
+}
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index a61759c24..b43f4ab9e 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -23,6 +23,9 @@ 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 {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
+import {PhotoCarouselPicker} from './PhotoCarouselPicker'
+import {SelectedPhoto} from './SelectedPhoto'
 
 const MAX_TEXT_LENGTH = 256
 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
@@ -41,14 +44,22 @@ export const ComposePost = observer(function ComposePost({
   const [isProcessing, setIsProcessing] = useState(false)
   const [error, setError] = useState('')
   const [text, setText] = useState('')
+  const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
+
   const autocompleteView = useMemo<UserAutocompleteViewModel>(
     () => new UserAutocompleteViewModel(store),
-    [],
+    [store],
+  )
+  const localPhotos = useMemo<UserLocalPhotosModel>(
+    () => new UserLocalPhotosModel(store),
+    [store],
   )
 
   useEffect(() => {
     autocompleteView.setup()
-  })
+    localPhotos.setup()
+  }, [autocompleteView, localPhotos])
+
   useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
@@ -60,9 +71,11 @@ export const ComposePost = observer(function ComposePost({
       }, 250)
     }
     return () => {
-      if (to) clearTimeout(to)
+      if (to) {
+        clearTimeout(to)
+      }
     }
-  }, [textInput.current])
+  }, [])
 
   const onChangeText = (newText: string) => {
     setText(newText)
@@ -116,6 +129,16 @@ export const ComposePost = observer(function ComposePost({
   const canPost = text.length <= MAX_TEXT_LENGTH
   const progressColor = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
 
+  const selectTextInputLayout =
+    selectedPhotos.length !== 0
+      ? styles.textInputLayoutWithPhoto
+      : styles.textInputLayoutWithoutPhoto
+  const selectTextInputPlaceholder = replyTo
+    ? 'Write your reply'
+    : selectedPhotos.length !== 0
+    ? 'Write a comment'
+    : "What's up?"
+
   const textDecorated = useMemo(() => {
     let i = 0
     return detectLinkables(text).map(v => {
@@ -192,7 +215,7 @@ export const ComposePost = observer(function ComposePost({
             </View>
           </View>
         ) : undefined}
-        <View style={styles.textInputLayout}>
+        <View style={[styles.textInputLayout, selectTextInputLayout]}>
           <UserAvatar
             handle={store.me.handle || ''}
             displayName={store.me.displayName}
@@ -203,13 +226,26 @@ export const ComposePost = observer(function ComposePost({
             multiline
             scrollEnabled
             onChangeText={(text: string) => onChangeText(text)}
-            placeholder={replyTo ? 'Write your reply' : "What's up?"}
+            placeholder={selectTextInputPlaceholder}
             style={styles.textInput}>
             {textDecorated}
           </TextInput>
         </View>
-        <View
-          style={[s.flexRow, {alignItems: 'center'}, s.pt10, s.pb10, s.pr5]}>
+        <SelectedPhoto
+          selectedPhotos={selectedPhotos}
+          setSelectedPhotos={setSelectedPhotos}
+        />
+        {localPhotos.photos != null &&
+          text === '' &&
+          selectedPhotos.length === 0 && (
+            <PhotoCarouselPicker
+              selectedPhotos={selectedPhotos}
+              setSelectedPhotos={setSelectedPhotos}
+              localPhotos={localPhotos}
+            />
+          )}
+        <View style={styles.separator} />
+        <View style={[s.flexRow, s.pt10, s.pb10, s.pr5, styles.contentCenter]}>
           <View style={s.flex1} />
           <Text style={[s.mr10, {color: progressColor}]}>
             {MAX_TEXT_LENGTH - text.length}
@@ -282,9 +318,14 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     marginRight: 5,
   },
+  textInputLayoutWithPhoto: {
+    flexWrap: 'wrap',
+  },
+  textInputLayoutWithoutPhoto: {
+    flex: 1,
+  },
   textInputLayout: {
     flexDirection: 'row',
-    flex: 1,
     borderTopWidth: 1,
     borderTopColor: colors.gray2,
     paddingTop: 16,
@@ -307,4 +348,10 @@ const styles = StyleSheet.create({
     paddingLeft: 13,
     paddingRight: 8,
   },
+  contentCenter: {alignItems: 'center'},
+  separator: {
+    borderBottomColor: 'black',
+    borderBottomWidth: StyleSheet.hairlineWidth,
+    width: '100%',
+  },
 })
diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/PhotoCarouselPicker.tsx
new file mode 100644
index 000000000..f4af4c61e
--- /dev/null
+++ b/src/view/com/composer/PhotoCarouselPicker.tsx
@@ -0,0 +1,128 @@
+import React, {useCallback} from 'react'
+import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from '../../lib/styles'
+import {
+  openPicker,
+  openCamera,
+  openCropper,
+} from 'react-native-image-crop-picker'
+
+export const PhotoCarouselPicker = ({
+  selectedPhotos,
+  setSelectedPhotos,
+  localPhotos,
+}: {
+  selectedPhotos: string[]
+  setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
+  localPhotos: any
+}) => {
+  const handleOpenCamera = useCallback(() => {
+    openCamera({
+      mediaType: 'photo',
+      cropping: true,
+      width: 1000,
+      height: 1000,
+    }).then(
+      item => {
+        setSelectedPhotos([item.path, ...selectedPhotos])
+      },
+      _err => {
+        // ignore
+      },
+    )
+  }, [selectedPhotos, setSelectedPhotos])
+
+  const handleSelectPhoto = useCallback(
+    async (uri: string) => {
+      const img = await openCropper({
+        mediaType: 'photo',
+        path: uri,
+        width: 1000,
+        height: 1000,
+      })
+      setSelectedPhotos([img.path, ...selectedPhotos])
+    },
+    [selectedPhotos, setSelectedPhotos],
+  )
+
+  const handleOpenGallery = useCallback(() => {
+    openPicker({
+      multiple: true,
+      maxFiles: 4,
+      mediaType: 'photo',
+    }).then(async items => {
+      const result = []
+
+      for await (const image of items) {
+        const img = await openCropper({
+          mediaType: 'photo',
+          path: image.path,
+          width: 1000,
+          height: 1000,
+        })
+        result.push(img.path)
+      }
+      setSelectedPhotos([...result, ...selectedPhotos])
+    })
+  }, [selectedPhotos, setSelectedPhotos])
+
+  return (
+    <ScrollView
+      horizontal
+      style={styles.photosContainer}
+      showsHorizontalScrollIndicator={false}>
+      <TouchableOpacity
+        style={[styles.galleryButton, styles.photo]}
+        onPress={handleOpenCamera}>
+        <FontAwesomeIcon
+          icon="camera"
+          size={24}
+          style={{color: colors.blue3}}
+        />
+      </TouchableOpacity>
+      {localPhotos.photos.map((item: any, index: number) => (
+        <TouchableOpacity
+          key={`local-image-${index}`}
+          style={styles.photoButton}
+          onPress={() => handleSelectPhoto(item.node.image.uri)}>
+          <Image style={styles.photo} source={{uri: item.node.image.uri}} />
+        </TouchableOpacity>
+      ))}
+      <TouchableOpacity
+        style={[styles.galleryButton, styles.photo]}
+        onPress={handleOpenGallery}>
+        <FontAwesomeIcon icon="image" style={{color: colors.blue3}} size={24} />
+      </TouchableOpacity>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  photosContainer: {
+    width: '100%',
+    maxHeight: 96,
+    padding: 8,
+    overflow: 'hidden',
+  },
+  galleryButton: {
+    borderWidth: 1,
+    borderColor: colors.gray3,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  photoButton: {
+    width: 75,
+    height: 75,
+    marginRight: 8,
+    borderWidth: 1,
+    borderRadius: 16,
+    borderColor: colors.gray3,
+  },
+  photo: {
+    width: 75,
+    height: 75,
+    marginRight: 8,
+    borderRadius: 16,
+  },
+})
diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx
new file mode 100644
index 000000000..88209b3df
--- /dev/null
+++ b/src/view/com/composer/SelectedPhoto.tsx
@@ -0,0 +1,87 @@
+import React, {useCallback} from 'react'
+import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from '../../lib/styles'
+
+export const SelectedPhoto = ({
+  selectedPhotos,
+  setSelectedPhotos,
+}: {
+  selectedPhotos: string[]
+  setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
+}) => {
+  const imageStyle =
+    selectedPhotos.length === 1
+      ? styles.image250
+      : selectedPhotos.length === 2
+      ? styles.image175
+      : styles.image85
+
+  const handleRemovePhoto = useCallback(
+    item => {
+      setSelectedPhotos(
+        selectedPhotos.filter(filterItem => filterItem !== item),
+      )
+    },
+    [selectedPhotos, setSelectedPhotos],
+  )
+
+  return selectedPhotos.length !== 0 ? (
+    <View style={styles.imageContainer}>
+      {selectedPhotos.length !== 0 &&
+        selectedPhotos.map((item, index) => (
+          <View
+            key={`selected-image-${index}`}
+            style={[styles.image, imageStyle]}>
+            <TouchableOpacity
+              onPress={() => handleRemovePhoto(item)}
+              style={styles.removePhotoButton}>
+              <FontAwesomeIcon
+                icon="xmark"
+                size={16}
+                style={{color: colors.white}}
+              />
+            </TouchableOpacity>
+
+            <Image style={[styles.image, imageStyle]} source={{uri: item}} />
+          </View>
+        ))}
+    </View>
+  ) : null
+}
+
+const styles = StyleSheet.create({
+  imageContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    marginTop: 16,
+  },
+  image: {
+    borderRadius: 8,
+    margin: 2,
+  },
+  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,
+  },
+})
diff --git a/src/view/index.ts b/src/view/index.ts
index e38e1debf..bd0e33cbe 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -56,6 +56,9 @@ import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
 import {faX} from '@fortawesome/free-solid-svg-icons/faX'
+import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
+import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
+import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 
 export function setup() {
   library.add(
@@ -115,5 +118,8 @@ export function setup() {
     faTicket,
     faTrashCan,
     faX,
+    faCamera,
+    faImage,
+    faXmark,
   )
 }