From d534c8d13caa896eeffc0f764ea28773d3364ff1 Mon Sep 17 00:00:00 2001 From: Volodymyr Smolianinov Date: Thu, 20 Aug 2020 21:34:47 +0200 Subject: [PATCH] Feature/attachment (#2) * File messages started * Type fixes and cleanup * File message design applied, file preview added * Code cleanup (#3) Co-authored-by: Alex --- example/index.js | 4 +- example/ios/Podfile.lock | 12 ++++ example/package.json | 3 + example/src/App.tsx | 58 ++++++++++++++++-- example/src/AppContainer.tsx | 13 ++++ example/yarn.lock | 38 ++++++++++++ jest/fixtures.ts | 13 +++- src/assets/icon-document.png | Bin 0 -> 254 bytes src/assets/icon-document@2x.png | Bin 0 -> 462 bytes src/assets/icon-document@3x.png | Bin 0 -> 678 bytes .../AttachmentButton/AttachmentButton.tsx | 1 - src/components/Chat/Chat.tsx | 8 ++- src/components/Chat/__tests__/Chat.test.tsx | 26 +++++++- src/components/FileMessage/FileMessage.tsx | 42 +++++++++++++ src/components/FileMessage/index.ts | 1 + src/components/FileMessage/styles.ts | 47 ++++++++++++++ src/components/ImageMessage/ImageMessage.tsx | 10 +-- .../__tests__/ImageMessage.test.tsx | 4 +- src/components/Input/Input.tsx | 52 +++++++++++----- src/components/Message/Message.tsx | 7 ++- src/components/SendButton/SendButton.tsx | 1 - src/types.ts | 37 +++++++++-- src/utils/__tests__/utils.test.ts | 14 ++++- src/utils/index.ts | 10 +++ 24 files changed, 359 insertions(+), 42 deletions(-) create mode 100644 example/src/AppContainer.tsx create mode 100644 src/assets/icon-document.png create mode 100644 src/assets/icon-document@2x.png create mode 100644 src/assets/icon-document@3x.png create mode 100644 src/components/FileMessage/FileMessage.tsx create mode 100644 src/components/FileMessage/index.ts create mode 100644 src/components/FileMessage/styles.ts diff --git a/example/index.js b/example/index.js index c6f88c4..68bfbb6 100644 --- a/example/index.js +++ b/example/index.js @@ -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) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1765bfd..6569b27 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -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 diff --git a/example/package.json b/example/package.json index 30698c0..d07aec8 100644 --- a/example/package.json +++ b/example/package.json @@ -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" }, diff --git a/example/src/App.tsx b/example/src/App.tsx index 0fed80b..7654eee 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -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 = () => { diff --git a/example/src/AppContainer.tsx b/example/src/AppContainer.tsx new file mode 100644 index 0000000..aed9432 --- /dev/null +++ b/example/src/AppContainer.tsx @@ -0,0 +1,13 @@ +import { ActionSheetProvider } from '@expo/react-native-action-sheet' +import React from 'react' +import App from './App' + +const AppContainer = () => { + return ( + + + + ) +} + +export default AppContainer diff --git a/example/yarn.lock b/example/yarn.lock index c52710d..9078ac1 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -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" diff --git a/jest/fixtures.ts b/jest/fixtures.ts index 90b96c1..a89768a 100644 --- a/jest/fixtures.ts +++ b/jest/fixtures.ts @@ -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, } diff --git a/src/assets/icon-document.png b/src/assets/icon-document.png new file mode 100644 index 0000000000000000000000000000000000000000..17be232e4ce07e717ec609233c06863da8eed664 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^LO?9S!3HE7rssMADb50q$YKTtZeb8+WSBKa0w~B> z9OUlAu^PTIZWLD&NVikba@| z%4L`1ohLIJrY;IidTVd5F-4_j$}~F%#mLuLC7k{Ty7KmWB=%}MI;nWh6tlMKcs6rN zX5twE+0>FEBmLPub+2CZ^%?!jEM$HEkzf6GaHKfvj|BqyqJLM(hMg9?yXeK|^A+b( w7BQZm>14(ASO~ z!!Qg$WhQ_PnhAgn7@->!Hedsn2^ay`pc?=azy>K3G#d!1PVd^QQ%jC~@kidBF4t?U zd$AMCwu1ruTCL**GH1oxTFqEC+{EsM` zR(vU;HH^S<2C${0xThMsg$%C19$p!I(sM2#gAzi_f}+JR>E3A?mKd^8lNY7#VnK}N9cp%#wDP8n*LrDIi;hKR&q+sg?0xDa(bv!+$%m4IFKXsDp^|ez2iYi zmDOG|1jVUM=$D1Qp&tHN=pE`|vp;^L_qv6!4xW-N&2P=&vwwo=c{%yT8$6tjbyN}M z+g>Yp-@iO|a6@7cj)ZS1RQsv~T_0Z%zU~dZHz(81KN^SK;N3{ly#N3J07*qoM6N<$ Ef)Hc39smFU literal 0 HcmV?d00001 diff --git a/src/assets/icon-document@3x.png b/src/assets/icon-document@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..056d99a868dc6dbbe4d93b6b3baee84d5f2deabd GIT binary patch literal 678 zcmV;X0$KfuP)=VhZQS zTEv{sn+L#B#J+HT{D_#f4$?5xt+RvtxP8zRmVwnAm;(Vvp4*wE*AM?A<~(0<)wv43#W zyd}K`K2&COU^&In$1q6W^d4~BLofd;9|P$U1zq|{F(H-G - {/* type-coverage:ignore-next-line */} ) diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 737406d..2c19726 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -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({}) 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>(null) @@ -65,8 +66,9 @@ export const Chat = ({ return ( { - setImageViewIndex(images.findIndex((image) => image.uri === imageUrl)) + onFilePress={onFilePress} + onImagePress={(url) => { + setImageViewIndex(images.findIndex((image) => image.uri === url)) setIsImageViewVisible(true) setStackEntry( StatusBar.pushStackEntry({ diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 93320d4..dea679d 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -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( + + ) + + const button = getByLabelText('Open a file') + fireEvent.press(button) + expect(onFilePress).toHaveBeenCalledWith(fileMessage) + }) }) diff --git a/src/components/FileMessage/FileMessage.tsx b/src/components/FileMessage/FileMessage.tsx new file mode 100644 index 0000000..50de867 --- /dev/null +++ b/src/components/FileMessage/FileMessage.tsx @@ -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 ( + + + + + + + + {message.name} + + {formatBytes(message.size)} + + + + ) +} diff --git a/src/components/FileMessage/index.ts b/src/components/FileMessage/index.ts new file mode 100644 index 0000000..6993842 --- /dev/null +++ b/src/components/FileMessage/index.ts @@ -0,0 +1 @@ +export * from './FileMessage' diff --git a/src/components/FileMessage/styles.ts b/src/components/FileMessage/styles.ts new file mode 100644 index 0000000..10189bc --- /dev/null +++ b/src/components/FileMessage/styles.ts @@ -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 diff --git a/src/components/ImageMessage/ImageMessage.tsx b/src/components/ImageMessage/ImageMessage.tsx index 37c2483..21a29e1 100644 --- a/src/components/ImageMessage/ImageMessage.tsx +++ b/src/components/ImageMessage/ImageMessage.tsx @@ -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 ( diff --git a/src/components/ImageMessage/__tests__/ImageMessage.test.tsx b/src/components/ImageMessage/__tests__/ImageMessage.test.tsx index 1a62c05..4f67972 100644 --- a/src/components/ImageMessage/__tests__/ImageMessage.test.tsx +++ b/src/components/ImageMessage/__tests__/ImageMessage.test.tsx @@ -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) }) }) diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 2777f05..5bb4d56 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -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[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 = ({ {user && ( )} 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 case 'image': return case 'text': diff --git a/src/components/SendButton/SendButton.tsx b/src/components/SendButton/SendButton.tsx index b272002..0abb76c 100644 --- a/src/components/SendButton/SendButton.tsx +++ b/src/components/SendButton/SendButton.tsx @@ -27,7 +27,6 @@ export const SendButton = ({ {...touchableOpacityProps} onPress={handlePress} > - {/* type-coverage:ignore-next-line */} ) diff --git a/src/types.ts b/src/types.ts index fb83dcd..ca57319 100644 --- a/src/types.ts +++ b/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 diff --git a/src/utils/__tests__/utils.test.ts b/src/utils/__tests__/utils.test.ts index 47e9a24..3b0ad84 100644 --- a/src/utils/__tests__/utils.test.ts +++ b/src/utils/__tests__/utils.test.ts @@ -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', () => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 0dc0f29..743e512 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,16 @@ import { User } from '../types' export const UserContext = React.createContext(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 }