diff --git a/__tests__/Storyshots.test.js b/__tests__/Storyshots.test.js index 0395eef24..60f449144 100644 --- a/__tests__/Storyshots.test.js +++ b/__tests__/Storyshots.test.js @@ -1,5 +1,21 @@ import initStoryshots from '@storybook/addon-storyshots'; +jest.mock('rn-fetch-blob', () => ({ + fs: { + dirs: { + DocumentDir: '/data/com.rocket.chat/documents', + DownloadDir: '/data/com.rocket.chat/downloads' + }, + exists: jest.fn(() => null) + }, + fetch: jest.fn(() => null), + config: jest.fn(() => null) +})); + +jest.mock('react-native-file-viewer', () => ({ + open: jest.fn(() => null) +})); + jest.mock('../app/lib/database', () => jest.fn(() => null)); global.Date.now = jest.fn(() => new Date('2019-10-10').getTime()); diff --git a/app/constants/colors.ts b/app/constants/colors.ts index ab358679c..b28416b86 100644 --- a/app/constants/colors.ts +++ b/app/constants/colors.ts @@ -65,6 +65,7 @@ export const themes: any = { previewBackground: '#1F2329', previewTintColor: '#ffffff', backdropOpacity: 0.3, + attachmentLoadingOpacity: 0.7, ...mentions }, dark: { @@ -112,6 +113,7 @@ export const themes: any = { previewBackground: '#030b1b', previewTintColor: '#ffffff', backdropOpacity: 0.9, + attachmentLoadingOpacity: 0.3, ...mentions }, black: { @@ -159,6 +161,7 @@ export const themes: any = { previewBackground: '#000000', previewTintColor: '#ffffff', backdropOpacity: 0.9, + attachmentLoadingOpacity: 0.3, ...mentions } }; diff --git a/app/constants/localPath.ts b/app/constants/localPath.ts new file mode 100644 index 000000000..704e2b6b6 --- /dev/null +++ b/app/constants/localPath.ts @@ -0,0 +1,4 @@ +import RNFetchBlob from 'rn-fetch-blob'; + +export const DOCUMENTS_PATH = `${RNFetchBlob.fs.dirs.DocumentDir}/`; +export const DOWNLOAD_PATH = `${RNFetchBlob.fs.dirs.DownloadDir}/`; diff --git a/app/containers/message/Reply.tsx b/app/containers/message/Reply.tsx index 5f4ca7657..fbc8984fc 100644 --- a/app/containers/message/Reply.tsx +++ b/app/containers/message/Reply.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import moment from 'moment'; import { transparentize } from 'color2k'; @@ -11,6 +11,9 @@ import openLink from '../../utils/openLink'; import sharedStyles from '../../views/Styles'; import { themes } from '../../constants/colors'; import MessageContext from './Context'; +import { fileDownloadAndPreview } from '../../utils/fileDownload'; +import { formatAttachmentUrl } from '../../lib/utils'; +import RCActivityIndicator from '../ActivityIndicator'; const styles = StyleSheet.create({ button: { @@ -28,6 +31,9 @@ const styles = StyleSheet.create({ flexDirection: 'column', padding: 15 }, + backdrop: { + ...StyleSheet.absoluteFillObject + }, authorContainer: { flex: 1, flexDirection: 'row', @@ -120,7 +126,7 @@ interface IMessageFields { } interface IMessageReply { - attachment: Partial; + attachment: IMessageReplyAttachment; timeFormat: string; index: number; theme: string; @@ -209,12 +215,14 @@ const Fields = React.memo( const Reply = React.memo( ({ attachment, timeFormat, index, getCustomEmoji, theme }: IMessageReply) => { + const [loading, setLoading] = useState(false); + if (!attachment) { return null; } const { baseUrl, user, jumpToMessage } = useContext(MessageContext); - const onPress = () => { + const onPress = async () => { let url = attachment.title_link || attachment.author_link; if (attachment.message_link) { return jumpToMessage(attachment.message_link); @@ -223,10 +231,11 @@ const Reply = React.memo( return; } if (attachment.type === 'file') { - if (!url.startsWith('http')) { - url = `${baseUrl}${url}`; - } - url = `${url}?rc_uid=${user.id}&rc_token=${user.token}`; + setLoading(true); + url = formatAttachmentUrl(attachment.title_link, user.id, user.token, baseUrl); + await fileDownloadAndPreview(url, attachment); + setLoading(false); + return; } openLink(url, theme); }; @@ -254,12 +263,23 @@ const Reply = React.memo( borderColor } ]} - background={Touchable.Ripple(themes[theme].bannerBackground)}> + background={Touchable.Ripple(themes[theme].bannerBackground)} + disabled={loading}> <UrlImage image={attachment.thumb_url} /> <Description attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} /> <Fields attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} /> + {loading ? ( + <View style={[styles.backdrop]}> + <View + style={[ + styles.backdrop, + { backgroundColor: themes[theme].bannerBackground, opacity: themes[theme].attachmentLoadingOpacity } + ]}></View> + <RCActivityIndicator theme={theme} /> + </View> + ) : null} </View> </Touchable> {/* @ts-ignore*/} diff --git a/app/containers/message/Video.tsx b/app/containers/message/Video.tsx index 5a9761412..afa5a68b2 100644 --- a/app/containers/message/Video.tsx +++ b/app/containers/message/Video.tsx @@ -1,15 +1,19 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { StyleSheet } from 'react-native'; import { dequal } from 'dequal'; import Touchable from './Touchable'; import Markdown from '../markdown'; -import openLink from '../../utils/openLink'; import { isIOS } from '../../utils/deviceInfo'; import { CustomIcon } from '../../lib/Icons'; import { formatAttachmentUrl } from '../../lib/utils'; import { themes } from '../../constants/colors'; import MessageContext from './Context'; +import { fileDownload } from '../../utils/fileDownload'; +import EventEmitter from '../../utils/events'; +import { LISTENER } from '../Toast'; +import I18n from '../../i18n'; +import RCActivityIndicator from '../ActivityIndicator'; const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/3gp', 'video/mkv'])]; const isTypeSupported = (type: any) => SUPPORTED_TYPES.indexOf(type) !== -1; @@ -27,6 +31,9 @@ const styles = StyleSheet.create({ interface IMessageVideo { file: { + title: string; + title_link: string; + type: string; video_type: string; video_url: string; description: string; @@ -39,15 +46,34 @@ interface IMessageVideo { const Video = React.memo( ({ file, showAttachment, getCustomEmoji, theme }: IMessageVideo) => { const { baseUrl, user } = useContext(MessageContext); + const [loading, setLoading] = useState(false); + if (!baseUrl) { return null; } - const onPress = () => { + const onPress = async () => { if (isTypeSupported(file.video_type)) { return showAttachment(file); } - const uri = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl); - openLink(uri, theme); + + if (!isIOS) { + const uri = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl); + await downloadVideo(uri); + return; + } + EventEmitter.emit(LISTENER, { message: I18n.t('Unsupported_format') }); + }; + + const downloadVideo = async (uri: string) => { + setLoading(true); + const fileDownloaded = await fileDownload(uri, file); + setLoading(false); + + if (fileDownloaded) { + EventEmitter.emit(LISTENER, { message: I18n.t('saved_to_gallery') }); + return; + } + EventEmitter.emit(LISTENER, { message: I18n.t('error-save-video') }); }; return ( @@ -56,7 +82,11 @@ const Video = React.memo( onPress={onPress} style={[styles.button, { backgroundColor: themes[theme].videoBackground }]} background={Touchable.Ripple(themes[theme].bannerBackground)}> - <CustomIcon name='play-filled' size={54} color={themes[theme].buttonText} /> + {loading ? ( + <RCActivityIndicator theme={theme} /> + ) : ( + <CustomIcon name='play-filled' size={54} color={themes[theme].buttonText} /> + )} </Touchable> {/* @ts-ignore*/} <Markdown diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index eede558f9..18dd52bc5 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -782,5 +782,8 @@ "No_canned_responses": "No canned responses", "Send_email_confirmation": "Send email confirmation", "sending_email_confirmation": "sending email confirmation", - "Enable_Message_Parser": "Enable Message Parser" -} \ No newline at end of file + "Enable_Message_Parser": "Enable Message Parser", + "Unsupported_format": "Unsupported format", + "Downloaded_file": "Downloaded file", + "Error_Download_file": "Error while downloading file" +} diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index fcc05ca22..24905e9f0 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -733,5 +733,8 @@ "Sharing": "Compartilhando", "No_canned_responses": "Não há respostas predefinidas", "Send_email_confirmation": "Enviar email de confirmação", - "sending_email_confirmation": "enviando email de confirmação" -} \ No newline at end of file + "sending_email_confirmation": "enviando email de confirmação", + "Unsupported_format": "Formato não suportado", + "Downloaded_file": "Arquivo baixado", + "Error_Download_file": "Erro ao baixar o arquivo" +} diff --git a/app/utils/fileDownload/index.ts b/app/utils/fileDownload/index.ts new file mode 100644 index 000000000..dda1a78ff --- /dev/null +++ b/app/utils/fileDownload/index.ts @@ -0,0 +1,59 @@ +import RNFetchBlob, { FetchBlobResponse } from 'rn-fetch-blob'; +import FileViewer from 'react-native-file-viewer'; + +import EventEmitter from '../events'; +import { LISTENER } from '../../containers/Toast'; +import I18n from '../../i18n'; +import { DOCUMENTS_PATH, DOWNLOAD_PATH } from '../../constants/localPath'; + +interface IAttachment { + title: string; + title_link: string; + type: string; + description: string; +} + +export const getLocalFilePathFromFile = (localPath: string, attachment: IAttachment): string => `${localPath}${attachment.title}`; + +export const fileDownload = (url: string, attachment: IAttachment): Promise<FetchBlobResponse> => { + const path = getLocalFilePathFromFile(DOWNLOAD_PATH, attachment); + + const options = { + path, + timeout: 10000, + indicator: true, + overwrite: true, + addAndroidDownloads: { + path, + notification: true, + useDownloadManager: true + } + }; + + return RNFetchBlob.config(options).fetch('GET', url); +}; + +export const fileDownloadAndPreview = async (url: string, attachment: IAttachment): Promise<void> => { + try { + const path = getLocalFilePathFromFile(DOCUMENTS_PATH, attachment); + const file = await RNFetchBlob.config({ + timeout: 10000, + indicator: true, + path + }).fetch('GET', url); + + FileViewer.open(file.data, { + showOpenWithDialog: true, + showAppsSuggestions: true + }) + .then(res => res) + .catch(async () => { + const file = await fileDownload(url, attachment); + file + ? EventEmitter.emit(LISTENER, { message: I18n.t('Downloaded_file') }) + : EventEmitter.emit(LISTENER, { message: I18n.t('Error_Download_file') }); + }); + } catch (e) { + EventEmitter.emit(LISTENER, { message: I18n.t('Error_Download_file') }); + } +}; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index aaf432c41..8ebb04855 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -532,6 +532,8 @@ PODS: - Firebase/Crashlytics (~> 6.27.0) - React - RNFBApp + - RNFileViewer (2.1.4): + - React-Core - RNGestureHandler (1.10.3): - React-Core - RNImageCropPicker (0.36.3): @@ -700,6 +702,7 @@ DEPENDENCIES: - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" - "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)" + - RNFileViewer (from `../node_modules/react-native-file-viewer`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) - RNLocalize (from `../node_modules/react-native-localize`) @@ -894,6 +897,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-firebase/app" RNFBCrashlytics: :path: "../node_modules/@react-native-firebase/crashlytics" + RNFileViewer: + :path: "../node_modules/react-native-file-viewer" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNImageCropPicker: @@ -1028,6 +1033,7 @@ SPEC CHECKSUMS: RNFBAnalytics: dae6d7b280ba61c96e1bbdd34aca3154388f025e RNFBApp: 6fd8a7e757135d4168bf033a8812c241af7363a0 RNFBCrashlytics: 88de72c2476b5868a892d9523b89b86c527c540e + RNFileViewer: 83cc066ad795b1f986791d03b56fe0ee14b6a69f RNGestureHandler: a479ebd5ed4221a810967000735517df0d2db211 RNImageCropPicker: 97289cd94fb01ab79db4e5c92938be4d0d63415d RNLocalize: 82a569022724d35461e2dc5b5d015a13c3ca995b diff --git a/package.json b/package.json index d421bfc7b..de67a1fbd 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "react-native-document-picker": "5.2.0", "react-native-easy-grid": "^0.2.2", "react-native-easy-toast": "^1.2.0", + "react-native-file-viewer": "^2.1.4", "react-native-gesture-handler": "^1.10.3", "react-native-image-crop-picker": "RocketChat/react-native-image-crop-picker", "react-native-image-progress": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index f4ad938a8..d3b73f392 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14254,6 +14254,11 @@ react-native-easy-toast@^1.2.0: dependencies: prop-types "^15.5.10" +react-native-file-viewer@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/react-native-file-viewer/-/react-native-file-viewer-2.1.4.tgz#987b2902f0f0ac87b42f3ac3d3037c8ae98f17a6" + integrity sha512-G3ko9lmqxT+lWhsDNy2K3Jes6xSMsUvlYwuwnRCNk2wC6hgYMeoeaiwDt8R3CdON781hB6Ej1eu3ir1QATtHXg== + react-native-flipper@^0.34.0: version "0.34.0" resolved "https://registry.yarnpkg.com/react-native-flipper/-/react-native-flipper-0.34.0.tgz#7df1f38ba5d97a9321125fe0fccbe47d99e6fa1d"