Add image picker to composer (#344)
* feat: add image uploader feature * fix: minor fixes * feat: add button when has images or input has value * fix: composer props * fix: minor issues
This commit is contained in:
parent
94a02ba6d6
commit
c0dc3b4497
|
@ -84,13 +84,18 @@ function App() {
|
||||||
onMembersPress={() => setShowMembers(show => !show)}
|
onMembersPress={() => setShowMembers(show => !show)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div id="content" ref={refMessagesContainer}>
|
<div
|
||||||
|
id="content"
|
||||||
|
ref={refMessagesContainer}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div id="messages">
|
||||||
<Messages />
|
<Messages />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="composer">
|
<div id="composer">
|
||||||
<Composer isBlurred={shouldBlurBottom} reply={false} />
|
<Composer isBlurred={shouldBlurBottom} reply={false} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<AnimatePresence enterVariant="fromRight" exitVariant="fromLeft">
|
<AnimatePresence enterVariant="fromRight" exitVariant="fromLeft">
|
||||||
{showMembers && (
|
{showMembers && (
|
||||||
|
|
|
@ -41,16 +41,20 @@ body,
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 72px 8px 132px 8px;
|
padding: 40px 0px 0px 0px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin-top: -56px;
|
margin-top: -56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#messages {
|
||||||
|
padding: 32px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
#composer {
|
#composer {
|
||||||
position: absolute;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
#members {
|
#members {
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
export { useBlur } from './use-blur'
|
export { useBlur } from './use-blur'
|
||||||
|
export { useImageUpload } from './use-image-uploader'
|
||||||
export { useThrottle } from './use-throttle'
|
export { useThrottle } from './use-throttle'
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface UseImageUploadReturn {
|
||||||
|
imagesData: string[]
|
||||||
|
handleImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handleImageRemove: (index: number) => void
|
||||||
|
imageUploaderInputRef: React.RefObject<HTMLInputElement>
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
const ALLOWED_EXTENSIONS = /(\.jpg|\.jpeg|\.png)$/i
|
||||||
|
const IMAGES_LIMIT = 6
|
||||||
|
|
||||||
|
const useImageUpload = (): UseImageUploadReturn => {
|
||||||
|
const [imagesData, setImagesData] = useState<string[]>([])
|
||||||
|
const imageUploaderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleImageUpload = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const files = event.target.files
|
||||||
|
|
||||||
|
if (!files) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredFiles = [...files].filter(file =>
|
||||||
|
ALLOWED_EXTENSIONS.test(file.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show alert if some files have unsupported formats
|
||||||
|
if (files.length > filteredFiles.length) {
|
||||||
|
return alert(
|
||||||
|
`Some files have unsupported formats. Only .jpg, .jpeg and .png formats are supported.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > IMAGES_LIMIT || imagesData.length > IMAGES_LIMIT) {
|
||||||
|
return alert(
|
||||||
|
`You can upload only ${IMAGES_LIMIT} images. Please remove some files and try again.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newImagesData: string[] = await Promise.all(
|
||||||
|
filteredFiles.map(async file => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
return new Promise(resolve => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
setImagesData(prevState => [...prevState, ...newImagesData])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageRemove = (index: number) => {
|
||||||
|
// Reset input value to trigger onChange event
|
||||||
|
imageUploaderInputRef.current!.value = ''
|
||||||
|
setImagesData(prevState => prevState.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imagesData,
|
||||||
|
handleImageUpload,
|
||||||
|
handleImageRemove,
|
||||||
|
imageUploaderInputRef,
|
||||||
|
isDisabled: imagesData.length >= IMAGES_LIMIT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useImageUpload }
|
|
@ -1,15 +1,20 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useImageUpload } from '@status-im/components/hooks'
|
||||||
import {
|
import {
|
||||||
|
ArrowUpIcon,
|
||||||
AudioIcon,
|
AudioIcon,
|
||||||
|
ClearIcon,
|
||||||
FormatIcon,
|
FormatIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
ReactionIcon,
|
ReactionIcon,
|
||||||
} from '@status-im/icons/20'
|
} from '@status-im/icons/20'
|
||||||
import { BlurView } from 'expo-blur'
|
import { BlurView } from 'expo-blur'
|
||||||
import { Stack, XStack, YStack } from 'tamagui'
|
import { AnimatePresence, Stack, XStack, YStack } from 'tamagui'
|
||||||
|
|
||||||
|
import { Button } from '../button'
|
||||||
import { IconButton } from '../icon-button'
|
import { IconButton } from '../icon-button'
|
||||||
|
import { Image } from '../image'
|
||||||
import { Input } from '../input'
|
import { Input } from '../input'
|
||||||
import { Reply } from '../reply'
|
import { Reply } from '../reply'
|
||||||
|
|
||||||
|
@ -22,26 +27,36 @@ const Composer = (props: Props) => {
|
||||||
const { isBlurred, reply } = props
|
const { isBlurred, reply } = props
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
const iconButtonBlurred = isBlurred && !isFocused
|
const {
|
||||||
|
imagesData,
|
||||||
|
handleImageUpload,
|
||||||
|
handleImageRemove,
|
||||||
|
imageUploaderInputRef,
|
||||||
|
isDisabled: isImageUploadDisabled,
|
||||||
|
} = useImageUpload()
|
||||||
|
|
||||||
|
const iconButtonBlurred = isBlurred && !isFocused && imagesData.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={40}
|
intensity={40}
|
||||||
style={{
|
style={{
|
||||||
zIndex: 100,
|
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
animation="fast"
|
animation="fast"
|
||||||
backgroundColor={isFocused ? '$background' : '$blurBackground'}
|
backgroundColor={iconButtonBlurred ? '$blurBackground' : '$background'}
|
||||||
shadowColor={!isBlurred || isFocused ? 'rgba(9, 16, 28, 0.08)' : 'none'}
|
shadowColor={iconButtonBlurred ? 'none' : 'rgba(9, 16, 28, 0.08)'}
|
||||||
shadowOffset={{ width: 4, height: !isBlurred || isFocused ? 4 : 0 }}
|
shadowOffset={{ width: 4, height: iconButtonBlurred ? 0 : 4 }}
|
||||||
shadowRadius={20}
|
shadowRadius={20}
|
||||||
borderTopLeftRadius={20}
|
borderTopLeftRadius={20}
|
||||||
borderTopRightRadius={20}
|
borderTopRightRadius={20}
|
||||||
px={16}
|
px={16}
|
||||||
|
width="100%"
|
||||||
py={12}
|
py={12}
|
||||||
style={{
|
style={{
|
||||||
elevation: 10,
|
elevation: 10,
|
||||||
|
@ -68,8 +83,78 @@ const Composer = (props: Props) => {
|
||||||
blurred={isBlurred}
|
blurred={isBlurred}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onChangeText={setText}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={imageUploaderInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
hidden
|
||||||
|
id="image-uploader-input"
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AnimatePresence>
|
||||||
|
{imagesData.length > 0 && (
|
||||||
|
<XStack
|
||||||
|
key="images-thumbnails"
|
||||||
|
paddingTop={12}
|
||||||
|
paddingBottom={8}
|
||||||
|
overflow="scroll"
|
||||||
|
>
|
||||||
|
{imagesData.map((imageData, index) => (
|
||||||
|
<Stack
|
||||||
|
key={index + imageData}
|
||||||
|
mr={12}
|
||||||
|
position="relative"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
flexDirection="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
animation={[
|
||||||
|
'fast',
|
||||||
|
{
|
||||||
|
opacity: {
|
||||||
|
overshootClamping: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
enterStyle={{ opacity: 0 }}
|
||||||
|
exitStyle={{ opacity: 0 }}
|
||||||
|
opacity={1}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={imageData}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
radius={12}
|
||||||
|
aspectRatio={1}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
zIndex={8}
|
||||||
|
onPress={() => handleImageRemove(index)}
|
||||||
|
cursor="pointer"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
backgroundColor="$background"
|
||||||
|
width={15}
|
||||||
|
height={15}
|
||||||
|
borderRadius={7}
|
||||||
|
position="absolute"
|
||||||
|
zIndex={1}
|
||||||
|
/>
|
||||||
|
<Stack position="absolute" zIndex={2} width={20}>
|
||||||
|
<ClearIcon color="$neutral-50" />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
|
@ -77,11 +162,14 @@ const Composer = (props: Props) => {
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
>
|
>
|
||||||
<Stack space={12} flexDirection="row" backgroundColor="transparent">
|
<Stack space={12} flexDirection="row" backgroundColor="transparent">
|
||||||
|
<label htmlFor="image-uploader-input">
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={<ImageIcon />}
|
icon={<ImageIcon />}
|
||||||
|
disabled={isImageUploadDisabled}
|
||||||
blurred={iconButtonBlurred}
|
blurred={iconButtonBlurred}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={<ReactionIcon />}
|
icon={<ReactionIcon />}
|
||||||
|
@ -94,11 +182,25 @@ const Composer = (props: Props) => {
|
||||||
blurred={iconButtonBlurred}
|
blurred={iconButtonBlurred}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{text || imagesData.length > 0 ? (
|
||||||
|
// TODO fix styles for circular button. Also the color is different from the design and we have layout shift because of the size.
|
||||||
|
<Button
|
||||||
|
icon={<ArrowUpIcon />}
|
||||||
|
height={32}
|
||||||
|
size={32}
|
||||||
|
width={32}
|
||||||
|
borderRadius={32}
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
type="positive"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={<AudioIcon />}
|
icon={<AudioIcon />}
|
||||||
blurred={iconButtonBlurred}
|
blurred={iconButtonBlurred}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
|
|
Loading…
Reference in New Issue