mirror of
https://github.com/zhigang1992/react-native-chat-ui.git
synced 2026-01-12 22:50:15 +08:00
Feature/attachment (#2)
* File messages started * Type fixes and cleanup * File message design applied, file preview added * Code cleanup (#3) Co-authored-by: Alex <alexdemchenko@yahoo.com>
This commit is contained in:
committed by
GitHub
parent
981b1f6bd7
commit
d534c8d13c
@@ -4,6 +4,6 @@
|
||||
|
||||
import { AppRegistry } from 'react-native'
|
||||
import { name as appName } from './app.json'
|
||||
import App from './src/App'
|
||||
import AppContainer from './src/AppContainer'
|
||||
|
||||
AppRegistry.registerComponent(appName, () => App)
|
||||
AppRegistry.registerComponent(appName, () => AppContainer)
|
||||
|
||||
@@ -236,6 +236,8 @@ PODS:
|
||||
- React-cxxreact (= 0.63.2)
|
||||
- React-jsi (= 0.63.2)
|
||||
- React-jsinspector (0.63.2)
|
||||
- react-native-document-picker (3.5.4):
|
||||
- React
|
||||
- react-native-image-picker (2.3.3):
|
||||
- React
|
||||
- react-native-safe-area-context (3.1.4):
|
||||
@@ -300,6 +302,8 @@ PODS:
|
||||
- React-Core (= 0.63.2)
|
||||
- React-cxxreact (= 0.63.2)
|
||||
- React-jsi (= 0.63.2)
|
||||
- RNFileViewer (2.1.1):
|
||||
- React
|
||||
- Yoga (1.14.0)
|
||||
- YogaKit (1.18.1):
|
||||
- Yoga (~> 1.14)
|
||||
@@ -341,6 +345,7 @@ DEPENDENCIES:
|
||||
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
|
||||
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
|
||||
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
|
||||
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||
@@ -353,6 +358,7 @@ DEPENDENCIES:
|
||||
- React-RCTText (from `../node_modules/react-native/Libraries/Text`)
|
||||
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- RNFileViewer (from `../node_modules/react-native-file-viewer`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -401,6 +407,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/jsiexecutor"
|
||||
React-jsinspector:
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector"
|
||||
react-native-document-picker:
|
||||
:path: "../node_modules/react-native-document-picker"
|
||||
react-native-image-picker:
|
||||
:path: "../node_modules/react-native-image-picker"
|
||||
react-native-safe-area-context:
|
||||
@@ -425,6 +433,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/Libraries/Vibration"
|
||||
ReactCommon:
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
RNFileViewer:
|
||||
:path: "../node_modules/react-native-file-viewer"
|
||||
Yoga:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
@@ -455,6 +465,7 @@ SPEC CHECKSUMS:
|
||||
React-jsi: 54245e1d5f4b690dec614a73a3795964eeef13a8
|
||||
React-jsiexecutor: 8ca588cc921e70590820ce72b8789b02c67cce38
|
||||
React-jsinspector: b14e62ebe7a66e9231e9581279909f2fc3db6606
|
||||
react-native-document-picker: 4921045c5c6c78243010f784eae0a3f4b2af7799
|
||||
react-native-image-picker: 3d3f85baabca60a00b75fb8facc1376db7bbdafa
|
||||
react-native-safe-area-context: eb91fe1fb3f7b87d9c30a7f0808407d8569d539d
|
||||
React-RCTActionSheet: 910163b6b09685a35c4ebbc52b66d1bfbbe39fc5
|
||||
@@ -467,6 +478,7 @@ SPEC CHECKSUMS:
|
||||
React-RCTText: 1b6773e776e4b33f90468c20fe3b16ca3e224bb8
|
||||
React-RCTVibration: 4d2e726957f4087449739b595f107c0d4b6c2d2d
|
||||
ReactCommon: a0a1edbebcac5e91338371b72ffc66aa822792ce
|
||||
RNFileViewer: db62d60dd19007c54b2c959b5e675a46d59f8a43
|
||||
Yoga: 7740b94929bbacbddda59bf115b5317e9a161598
|
||||
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/react-native-action-sheet": "^3.8.0",
|
||||
"react": "^16.13.1",
|
||||
"react-native": "^0.63.2",
|
||||
"react-native-document-picker": "^3.5.4",
|
||||
"react-native-file-viewer": "^2.1.1",
|
||||
"react-native-image-picker": "^2.3.3",
|
||||
"react-native-safe-area-context": "^3.1.4"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useActionSheet } from '@expo/react-native-action-sheet'
|
||||
import {
|
||||
Chat,
|
||||
MessageType,
|
||||
SendImageCallback,
|
||||
SendAttachmentCallback,
|
||||
} from '@flyerhq/react-native-chat-ui'
|
||||
import React, { useState } from 'react'
|
||||
import { StatusBar } from 'react-native'
|
||||
import DocumentPicker from 'react-native-document-picker'
|
||||
import FileViewer from 'react-native-file-viewer'
|
||||
import ImagePicker from 'react-native-image-picker'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import data from './messages.json'
|
||||
@@ -12,15 +15,61 @@ import users from './users.json'
|
||||
|
||||
const App = () => {
|
||||
const [messages, setMessages] = useState(data as MessageType.Any[])
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
|
||||
const handleAttachmentPress = (send: SendImageCallback) => {
|
||||
const handleAttachmentPress = (sendAttachment: SendAttachmentCallback) => {
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options: ['Photo', 'File', 'Cancel'],
|
||||
cancelButtonIndex: 2,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
switch (buttonIndex) {
|
||||
case 0:
|
||||
handleImageSelection(sendAttachment)
|
||||
break
|
||||
case 1:
|
||||
handleFileSelection(sendAttachment)
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleFilePress = async (file: MessageType.File) => {
|
||||
try {
|
||||
await FileViewer.open(file.url, { showOpenWithDialog: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleFileSelection = async (
|
||||
sendAttachment: SendAttachmentCallback
|
||||
) => {
|
||||
try {
|
||||
const response = await DocumentPicker.pick({
|
||||
type: [DocumentPicker.types.allFiles],
|
||||
})
|
||||
sendAttachment({
|
||||
mimeType: response.type,
|
||||
name: response.name,
|
||||
size: response.size,
|
||||
url: response.uri,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!DocumentPicker.isCancel(err)) {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageSelection = (sendAttachment: SendAttachmentCallback) => {
|
||||
ImagePicker.showImagePicker(
|
||||
{ maxWidth: 1440, quality: 0.7 },
|
||||
(response) => {
|
||||
if (response.data) {
|
||||
send({
|
||||
sendAttachment({
|
||||
height: response.height,
|
||||
imageUrl: 'data:image/jpeg;base64,' + response.data,
|
||||
url: 'data:image/jpeg;base64,' + response.data,
|
||||
width: response.width,
|
||||
})
|
||||
}
|
||||
@@ -38,6 +87,7 @@ const App = () => {
|
||||
<Chat
|
||||
messages={messages}
|
||||
onAttachmentPress={handleAttachmentPress}
|
||||
onFilePress={handleFilePress}
|
||||
onSendPress={handleSendPress}
|
||||
user={users[0]}
|
||||
/>
|
||||
|
||||
13
example/src/AppContainer.tsx
Normal file
13
example/src/AppContainer.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ActionSheetProvider } from '@expo/react-native-action-sheet'
|
||||
import React from 'react'
|
||||
import App from './App'
|
||||
|
||||
const AppContainer = () => {
|
||||
return (
|
||||
<ActionSheetProvider>
|
||||
<App />
|
||||
</ActionSheetProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppContainer
|
||||
@@ -1213,6 +1213,14 @@
|
||||
exec-sh "^0.3.2"
|
||||
minimist "^1.2.0"
|
||||
|
||||
"@expo/react-native-action-sheet@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@expo/react-native-action-sheet/-/react-native-action-sheet-3.8.0.tgz#0db8b70ea8550ceb2983abda8584efa3a61d7389"
|
||||
integrity sha512-tCfwysuqy0sfaN+aA98IKUrwCLKsbDHSYLcnHrx9wNbawOHNez8rSeFtieAS48/HyrPI75yg/ZGvxe6UsJRS8Q==
|
||||
dependencies:
|
||||
"@types/hoist-non-react-statics" "^3.3.1"
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
|
||||
"@hapi/address@2.x.x":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222"
|
||||
@@ -1702,6 +1710,14 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/hoist-non-react-statics@^3.3.1":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
|
||||
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
|
||||
@@ -3904,6 +3920,13 @@ hermes-engine@~0.5.0:
|
||||
resolved "https://registry.yarnpkg.com/hermes-engine/-/hermes-engine-0.5.1.tgz#601115e4b1e0a17d9aa91243b96277de4e926e09"
|
||||
integrity sha512-hLwqh8dejHayjlpvZY40e1aDCDvyP98cWx/L5DhAjSJLH8g4z9Tp08D7y4+3vErDsncPOdf1bxm+zUWpx0/Fxg==
|
||||
|
||||
hoist-non-react-statics@^3.3.0:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
hosted-git-info@^2.1.4:
|
||||
version "2.8.5"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
|
||||
@@ -6396,11 +6419,26 @@ react-is@^16.12.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"
|
||||
integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==
|
||||
|
||||
react-is@^16.7.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
|
||||
version "16.11.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.11.0.tgz#b85dfecd48ad1ce469ff558a882ca8e8313928fa"
|
||||
integrity sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw==
|
||||
|
||||
react-native-document-picker@^3.5.4:
|
||||
version "3.5.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native-document-picker/-/react-native-document-picker-3.5.4.tgz#4b9f261ea0d52e91d571670caaf55923ceb80f5f"
|
||||
integrity sha512-ZKGAa8ztQ7zA1eE95OCiNsI/Q6fiq1Q3es8MyOEakBkWcX9avWNYaJUrbv/8v80Vo4RzcNxsO3wT6a2hgfpz7A==
|
||||
|
||||
react-native-file-viewer@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-file-viewer/-/react-native-file-viewer-2.1.1.tgz#d0b4c51e9b38870ba2b9fb1d436df844e974f087"
|
||||
integrity sha512-Q5OtfA+VlprLaXmKk1tH/IFzMHwa2B5YnR4xfoEKOJ53UI5BKvYc67Y/ammUUog7bUwPkUBWenwd4MxBTi1R+g==
|
||||
|
||||
react-native-image-picker@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-2.3.3.tgz#eaacd4a1ed9e9887613be31f0765478ca2f18a51"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { MessageType, Size, User } from '../src/types'
|
||||
|
||||
export const fileMessage: MessageType.File = {
|
||||
authorId: 'userId',
|
||||
id: 'uuidv4',
|
||||
mimeType: 'application/pdf',
|
||||
name: 'flyer.pdf',
|
||||
size: 15000,
|
||||
timestamp: 0,
|
||||
type: 'file',
|
||||
url: 'file:///Users/admin/flyer.pdf',
|
||||
}
|
||||
|
||||
export const imageMessage: MessageType.Image = {
|
||||
authorId: 'userId',
|
||||
height: 100,
|
||||
id: 'uuidv4',
|
||||
imageUrl: 'https://avatars1.githubusercontent.com/u/59206044',
|
||||
timestamp: 0,
|
||||
type: 'image',
|
||||
url: 'https://avatars1.githubusercontent.com/u/59206044',
|
||||
width: 100,
|
||||
}
|
||||
|
||||
|
||||
BIN
src/assets/icon-document.png
Normal file
BIN
src/assets/icon-document.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 B |
BIN
src/assets/icon-document@2x.png
Normal file
BIN
src/assets/icon-document@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 462 B |
BIN
src/assets/icon-document@3x.png
Normal file
BIN
src/assets/icon-document@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 678 B |
@@ -27,7 +27,6 @@ export const AttachmentButton = ({
|
||||
{...touchableOpacityProps}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{/* type-coverage:ignore-next-line */}
|
||||
<Image source={require('../../assets/icon-attachment.png')} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ChatProps extends InputProps {
|
||||
export const Chat = ({
|
||||
messages,
|
||||
onAttachmentPress,
|
||||
onFilePress,
|
||||
onSendPress,
|
||||
textInputProps,
|
||||
user,
|
||||
@@ -37,7 +38,7 @@ export const Chat = ({
|
||||
const [stackEntry, setStackEntry] = React.useState<StatusBarProps>({})
|
||||
const images = messages
|
||||
.filter((message): message is MessageType.Image => message.type === 'image')
|
||||
.map((message) => ({ uri: message.imageUrl }))
|
||||
.map((message) => ({ uri: message.url }))
|
||||
.reverse()
|
||||
|
||||
const list = React.useRef<FlatList<MessageType.Any>>(null)
|
||||
@@ -65,8 +66,9 @@ export const Chat = ({
|
||||
return (
|
||||
<Message
|
||||
message={item}
|
||||
onImagePress={(imageUrl) => {
|
||||
setImageViewIndex(images.findIndex((image) => image.uri === imageUrl))
|
||||
onFilePress={onFilePress}
|
||||
onImagePress={(url) => {
|
||||
setImageViewIndex(images.findIndex((image) => image.uri === url))
|
||||
setIsImageViewVisible(true)
|
||||
setStackEntry(
|
||||
StatusBar.pushStackEntry({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
import { imageMessage, textMessage, user } from '../../../../jest/fixtures'
|
||||
import {
|
||||
fileMessage,
|
||||
imageMessage,
|
||||
textMessage,
|
||||
user,
|
||||
} from '../../../../jest/fixtures'
|
||||
import { Chat } from '../Chat'
|
||||
|
||||
describe('chat', () => {
|
||||
@@ -34,4 +39,23 @@ describe('chat', () => {
|
||||
fireEvent.press(button)
|
||||
expect(onSendPress).toHaveBeenCalledWith(textMessage)
|
||||
})
|
||||
|
||||
it('opens file on a file message tap', () => {
|
||||
expect.assertions(1)
|
||||
const messages = [fileMessage]
|
||||
const onSendPress = jest.fn()
|
||||
const onFilePress = jest.fn()
|
||||
const { getByLabelText } = render(
|
||||
<Chat
|
||||
onFilePress={onFilePress}
|
||||
messages={messages}
|
||||
onSendPress={onSendPress}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
|
||||
const button = getByLabelText('Open a file')
|
||||
fireEvent.press(button)
|
||||
expect(onFilePress).toHaveBeenCalledWith(fileMessage)
|
||||
})
|
||||
})
|
||||
|
||||
42
src/components/FileMessage/FileMessage.tsx
Normal file
42
src/components/FileMessage/FileMessage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { Image, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { MessageType } from '../../types'
|
||||
import { formatBytes, UserContext } from '../../utils'
|
||||
import styles from './styles'
|
||||
|
||||
export interface FileMessageProps {
|
||||
message: MessageType.File
|
||||
onPress?: (file: MessageType.File) => void
|
||||
}
|
||||
|
||||
export const FileMessage = ({ message, onPress }: FileMessageProps) => {
|
||||
const user = React.useContext(UserContext)
|
||||
const { container, iconContainer, name, size, textContainer } = styles({
|
||||
message,
|
||||
user,
|
||||
})
|
||||
|
||||
const handlePress = () => {
|
||||
onPress?.(message)
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel='Open a file'
|
||||
onPress={handlePress}
|
||||
>
|
||||
<View style={container}>
|
||||
<View style={iconContainer}>
|
||||
<Image source={require('../../assets/icon-document.png')} />
|
||||
</View>
|
||||
<View style={textContainer}>
|
||||
<Text accessibilityRole='text' style={name}>
|
||||
{message.name}
|
||||
</Text>
|
||||
<Text style={size}>{formatBytes(message.size)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
1
src/components/FileMessage/index.ts
Normal file
1
src/components/FileMessage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './FileMessage'
|
||||
47
src/components/FileMessage/styles.ts
Normal file
47
src/components/FileMessage/styles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { MessageType, User } from '../../types'
|
||||
|
||||
const styles = ({
|
||||
message,
|
||||
user,
|
||||
}: {
|
||||
message: MessageType.File
|
||||
user?: User
|
||||
}) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor:
|
||||
user?.id === message.authorId ? '#ffffff33' : '#2e2c2c33',
|
||||
borderRadius: 22,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
marginLeft: 16,
|
||||
marginVertical: 16,
|
||||
width: 44,
|
||||
},
|
||||
name: {
|
||||
color: user?.id === message.authorId ? '#fff' : '#2e2c2c',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
},
|
||||
size: {
|
||||
color: user?.id === message.authorId ? '#ffffff66' : '#2e2c2c66',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
lineHeight: 16,
|
||||
marginTop: 2,
|
||||
},
|
||||
textContainer: {
|
||||
flexShrink: 1,
|
||||
marginLeft: 12,
|
||||
marginRight: 24,
|
||||
marginVertical: 16,
|
||||
},
|
||||
})
|
||||
|
||||
export default styles
|
||||
@@ -5,7 +5,7 @@ import styles from './styles'
|
||||
|
||||
export interface ImageMessageProps {
|
||||
message: MessageType.Image
|
||||
onPress: (imageUrl: string) => void
|
||||
onPress: (url: string) => void
|
||||
}
|
||||
|
||||
export const ImageMessage = ({ message, onPress }: ImageMessageProps) => {
|
||||
@@ -20,21 +20,21 @@ export const ImageMessage = ({ message, onPress }: ImageMessageProps) => {
|
||||
React.useEffect(() => {
|
||||
if (defaultHeight <= 0 || defaultWidth <= 0)
|
||||
Image.getSize(
|
||||
message.imageUrl,
|
||||
message.url,
|
||||
(width, height) => setSize({ height, width }),
|
||||
() => setSize({ height: 0, width: 0 })
|
||||
)
|
||||
}, [defaultHeight, defaultWidth, message.imageUrl])
|
||||
}, [defaultHeight, defaultWidth, message.url])
|
||||
|
||||
const handlePress = () => {
|
||||
onPress(message.imageUrl)
|
||||
onPress(message.url)
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={handlePress}>
|
||||
<Image
|
||||
accessibilityRole='image'
|
||||
source={{ uri: message.imageUrl }}
|
||||
source={{ uri: message.url }}
|
||||
style={image}
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('text message', () => {
|
||||
)
|
||||
expect(getSizeMock).toHaveBeenCalledTimes(1)
|
||||
const getSizeArgs = getSizeMock.mock.calls[0]
|
||||
expect(getSizeArgs[0]).toBe(imageMessage.imageUrl)
|
||||
expect(getSizeArgs[0]).toBe(imageMessage.url)
|
||||
const success = getSizeArgs[1]
|
||||
const error = getSizeArgs[2]
|
||||
act(() => {
|
||||
@@ -44,6 +44,6 @@ describe('text message', () => {
|
||||
)
|
||||
const button = getByRole('image').parent
|
||||
fireEvent.press(button)
|
||||
expect(onPress).toHaveBeenCalledWith(imageMessage.imageUrl)
|
||||
expect(onPress).toHaveBeenCalledWith(imageMessage.url)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,15 +7,22 @@ import {
|
||||
TextInputProps,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { MessageType, SendImageCallback } from '../../types'
|
||||
import {
|
||||
MessageType,
|
||||
SendAttachmentCallback,
|
||||
SendAttachmentCallbackParams,
|
||||
SendFileCallbackParams,
|
||||
SendImageCallbackParams,
|
||||
} from '../../types'
|
||||
import { UserContext, uuidv4 } from '../../utils'
|
||||
import { AttachmentButton } from '../AttachmentButton'
|
||||
import { SendButton } from '../SendButton'
|
||||
import styles from './styles'
|
||||
|
||||
export interface InputProps {
|
||||
onAttachmentPress?: (send: SendImageCallback) => void
|
||||
onAttachmentPress?: (sendAttachment: SendAttachmentCallback) => void
|
||||
onContentBottomInsetUpdate?: (contentBottomInset: number) => void
|
||||
onFilePress?: (file: MessageType.File) => void
|
||||
onSendPress: (message: MessageType.Any) => void
|
||||
panResponderPositionY?: Animated.Value
|
||||
textInputProps?: TextInputProps
|
||||
@@ -59,18 +66,33 @@ export const Input = ({
|
||||
|
||||
// TODO: This function is binded to the `onAttachmentPress`, how to mock this in tests?
|
||||
/* istanbul ignore next */
|
||||
const handleSendImage = ({
|
||||
height,
|
||||
imageUrl,
|
||||
width,
|
||||
}: Parameters<SendImageCallback>[0]) => {
|
||||
onSendPress({
|
||||
...defaultMessageParams(),
|
||||
height,
|
||||
imageUrl,
|
||||
type: 'image',
|
||||
width,
|
||||
})
|
||||
const handleSendAttachment = (params: SendAttachmentCallbackParams) => {
|
||||
const isFileParams = (
|
||||
arg: SendFileCallbackParams | SendImageCallbackParams
|
||||
): arg is SendFileCallbackParams => {
|
||||
return 'name' in arg
|
||||
}
|
||||
|
||||
if (isFileParams(params)) {
|
||||
const { mimeType, name, size, url } = params
|
||||
onSendPress({
|
||||
...defaultMessageParams(),
|
||||
mimeType,
|
||||
name,
|
||||
size,
|
||||
type: 'file',
|
||||
url,
|
||||
})
|
||||
} else {
|
||||
const { height, url, width } = params
|
||||
onSendPress({
|
||||
...defaultMessageParams(),
|
||||
height,
|
||||
type: 'image',
|
||||
url,
|
||||
width,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -82,7 +104,7 @@ export const Input = ({
|
||||
<View style={styles.container}>
|
||||
{user && (
|
||||
<AttachmentButton
|
||||
onPress={onAttachmentPress?.bind(null, handleSendImage)}
|
||||
onPress={onAttachmentPress?.bind(null, handleSendAttachment)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
|
||||
@@ -2,19 +2,22 @@ import * as React from 'react'
|
||||
import { View } from 'react-native'
|
||||
import { MessageType, Size } from '../../types'
|
||||
import { UserContext } from '../../utils'
|
||||
import { FileMessage } from '../FileMessage'
|
||||
import { ImageMessage } from '../ImageMessage'
|
||||
import { TextMessage } from '../TextMessage'
|
||||
import styles from './styles'
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType.Any
|
||||
onImagePress: (imageUrl: string) => void
|
||||
onFilePress?: (file: MessageType.File) => void
|
||||
onImagePress: (url: string) => void
|
||||
parentComponentSize: Size
|
||||
previousMessageSameAuthor: boolean
|
||||
}
|
||||
|
||||
export const Message = ({
|
||||
message,
|
||||
onFilePress,
|
||||
onImagePress,
|
||||
parentComponentSize,
|
||||
previousMessageSameAuthor,
|
||||
@@ -29,6 +32,8 @@ export const Message = ({
|
||||
|
||||
const renderMessage = () => {
|
||||
switch (message.type) {
|
||||
case 'file':
|
||||
return <FileMessage message={message} onPress={onFilePress} />
|
||||
case 'image':
|
||||
return <ImageMessage message={message} onPress={onImagePress} />
|
||||
case 'text':
|
||||
|
||||
@@ -27,7 +27,6 @@ export const SendButton = ({
|
||||
{...touchableOpacityProps}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{/* type-coverage:ignore-next-line */}
|
||||
<Image source={require('../../assets/icon-send.png')} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
37
src/types.ts
37
src/types.ts
@@ -1,6 +1,6 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace MessageType {
|
||||
export type Any = Image | Text
|
||||
export type Any = File | Image | Text
|
||||
|
||||
interface Base {
|
||||
authorId: string
|
||||
@@ -8,10 +8,18 @@ export namespace MessageType {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface File extends Base {
|
||||
mimeType?: string
|
||||
name: string
|
||||
size: number
|
||||
type: 'file'
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Image extends Base {
|
||||
height?: number
|
||||
imageUrl: string
|
||||
type: 'image'
|
||||
url: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
@@ -21,11 +29,30 @@ export namespace MessageType {
|
||||
}
|
||||
}
|
||||
|
||||
export type SendImageCallback = (payload: {
|
||||
export type SendAttachmentCallback = (
|
||||
payload: SendAttachmentCallbackParams
|
||||
) => void
|
||||
|
||||
export type SendAttachmentCallbackParams =
|
||||
| SendFileCallbackParams
|
||||
| SendImageCallbackParams
|
||||
|
||||
export type SendFileCallback = (payload: SendFileCallbackParams) => void
|
||||
|
||||
export interface SendFileCallbackParams {
|
||||
mimeType?: string
|
||||
name: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export type SendImageCallback = (payload: SendImageCallbackParams) => void
|
||||
|
||||
export interface SendImageCallbackParams {
|
||||
height?: number
|
||||
imageUrl: string
|
||||
url: string
|
||||
width?: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
height: number
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { getTextSizeInBytes, uuidv4 } from '..'
|
||||
import { formatBytes, getTextSizeInBytes, uuidv4 } from '..'
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats bytes correctly when the size is 0', () => {
|
||||
expect.assertions(1)
|
||||
expect(formatBytes(0)).toStrictEqual('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes correctly', () => {
|
||||
expect.assertions(1)
|
||||
expect(formatBytes(1024)).toStrictEqual('1 kB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTextSizeInBytes', () => {
|
||||
it('calculates the size for a simple text', () => {
|
||||
|
||||
@@ -4,6 +4,16 @@ import { User } from '../types'
|
||||
|
||||
export const UserContext = React.createContext<User | undefined>(undefined)
|
||||
|
||||
export const formatBytes = (size: number, fractionDigits = 2) => {
|
||||
if (size === 0) return '0 B'
|
||||
const multiple = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return (
|
||||
parseFloat((size / Math.pow(1024, multiple)).toFixed(fractionDigits)) +
|
||||
' ' +
|
||||
['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'][multiple]
|
||||
)
|
||||
}
|
||||
|
||||
export const getTextSizeInBytes = (text: string) => {
|
||||
return new Blob([text]).size
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user