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>
This commit is contained in:
parent
7ae1bac620
commit
67c4dcff37
10 changed files with 391 additions and 58 deletions
27
src/state/models/user-local-photos.ts
Normal file
27
src/state/models/user-local-photos.ts
Normal file
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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%',
|
||||
},
|
||||
})
|
||||
|
|
128
src/view/com/composer/PhotoCarouselPicker.tsx
Normal file
128
src/view/com/composer/PhotoCarouselPicker.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
87
src/view/com/composer/SelectedPhoto.tsx
Normal file
87
src/view/com/composer/SelectedPhoto.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue