diff --git a/app/actions/views/file_preview.js b/app/actions/views/file_preview.js new file mode 100644 index 000000000..4f913ace3 --- /dev/null +++ b/app/actions/views/file_preview.js @@ -0,0 +1,11 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ViewTypes} from 'app/constants'; + +export function addFileToFetchCache(url) { + return { + type: ViewTypes.ADD_FILE_TO_FETCH_CACHE, + url + }; +} diff --git a/app/actions/views/file_upload.js b/app/actions/views/file_upload.js index 404c4e6e7..4a637ee82 100644 --- a/app/actions/views/file_upload.js +++ b/app/actions/views/file_upload.js @@ -16,12 +16,15 @@ export function handleUploadFiles(files, rootId, requestId) { const teamId = state.entities.teams.currentTeamId; const channelId = state.entities.channels.currentChannelId; const formData = new FormData(); + const clientIds = []; files.forEach((file) => { const name = file.path.split('/').pop(); const mimeType = lookupMimeType(name); const clientId = generateId(); + clientIds.push(clientId); + const fileData = { uri: file.path, name, @@ -38,6 +41,13 @@ export function handleUploadFiles(files, rootId, requestId) { formBoundary = '--mobile.client.file.upload'; } + dispatch({ + type: ViewTypes.SET_TEMP_UPLOAD_FILES_FOR_POST_DRAFT, + clientIds, + channelId, + rootId + }); + await uploadFile(teamId, channelId, formData, formBoundary, rootId, requestId)(dispatch, getState); }; } @@ -50,10 +60,10 @@ export function handleClearFiles(channelId, rootId) { }; } -export function handleRemoveFile(fileId, channelId, rootId) { +export function handleRemoveFile(clientId, channelId, rootId) { return { type: ViewTypes.REMOVE_FILE_FROM_POST_DRAFT, - fileId, + clientId, channelId, rootId }; diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 5deb82e94..3a638a4a8 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -22,6 +22,8 @@ import FileAttachmentPreview from './file_attachment_preview'; export default class FileAttachment extends PureComponent { static propTypes = { + addFileToFetchCache: PropTypes.func.isRequired, + fetchCache: PropTypes.object.isRequired, file: PropTypes.object.isRequired, theme: PropTypes.object.isRequired }; @@ -60,6 +62,8 @@ export default class FileAttachment extends PureComponent { if (file.has_preview_image) { fileAttachmentComponent = ( diff --git a/app/components/file_attachment_list/file_attachment_list.js b/app/components/file_attachment_list/file_attachment_list.js index 0a67afe75..1cb5bdcb3 100644 --- a/app/components/file_attachment_list/file_attachment_list.js +++ b/app/components/file_attachment_list/file_attachment_list.js @@ -16,6 +16,7 @@ import FileAttachment from './file_attachment'; export default class FileAttachmentList extends Component { static propTypes = { actions: PropTypes.object.isRequired, + fetchCache: PropTypes.object.isRequired, files: PropTypes.array.isRequired, hideOptionsContext: PropTypes.func.isRequired, onLongPress: PropTypes.func, @@ -44,6 +45,8 @@ export default class FileAttachmentList extends Component { onPressOut={() => this.props.toggleSelected(false)} > diff --git a/app/components/file_attachment_list/file_attachment_list_container.js b/app/components/file_attachment_list/file_attachment_list_container.js index 176424d7b..3f80ef785 100644 --- a/app/components/file_attachment_list/file_attachment_list_container.js +++ b/app/components/file_attachment_list/file_attachment_list_container.js @@ -6,6 +6,7 @@ import {connect} from 'react-redux'; import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; import {loadFilesForPostIfNecessary} from 'app/actions/views/channel'; +import {addFileToFetchCache} from 'app/actions/views/file_preview'; import {getTheme} from 'app/selectors/preferences'; import {goToImagePreviewModal} from 'app/actions/navigation'; @@ -16,6 +17,7 @@ function makeMapStateToProps() { return function mapStateToProps(state, ownProps) { return { ...ownProps, + fetchCache: state.views.fetchCache, files: getFilesForPost(state, ownProps.post), theme: getTheme(state) }; @@ -25,6 +27,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + addFileToFetchCache, goToImagePreviewModal, loadFilesForPostIfNecessary }, dispatch) diff --git a/app/components/file_attachment_list/file_attachment_preview.js b/app/components/file_attachment_list/file_attachment_preview.js index d5d9517ca..a5260540d 100644 --- a/app/components/file_attachment_list/file_attachment_preview.js +++ b/app/components/file_attachment_list/file_attachment_preview.js @@ -6,6 +6,8 @@ import React, { PureComponent } from 'react'; import { + ActivityIndicator, + Animated, View, Image, StyleSheet @@ -13,67 +15,110 @@ import { import Client from 'mattermost-redux/client'; -import genericIcon from 'assets/images/icons/generic.png'; +const {View: AnimatedView} = Animated; export default class FileAttachmentPreview extends PureComponent { static propTypes = { - file: PropTypes.object.isRequired, + addFileToFetchCache: PropTypes.func.isRequired, + fetchCache: PropTypes.object.isRequired, + file: PropTypes.object, imageHeight: PropTypes.number, imageWidth: PropTypes.number, resizeMode: PropTypes.string, resizeMethod: PropTypes.string, + wrapperBackgroundColor: PropTypes.string, wrapperHeight: PropTypes.number, wrapperWidth: PropTypes.number }; static defaultProps = { + fadeInOnLoad: false, imageHeight: 100, imageWidth: 100, + loading: false, resizeMode: 'cover', resizeMethod: 'resize', + wrapperBackgroundColor: '#fff', wrapperHeigh: 100, wrapperWidth: 100 }; state = { + opacity: new Animated.Value(0), + requesting: true, retry: 0 } // Sometimes the request after a file upload errors out. // We'll up to three times to get the image. // We have to add a timestamp so fetch will retry the call. - handleError = () => { + handleLoadError = () => { if (this.state.retry < 3) { - setTimeout(() => this.setState({ + this.setState({ retry: this.state.retry++, timestamp: Date.now() - }), 100); + }); } } + handleLoad = () => { + this.setState({ + requesting: false + }); + + Animated.timing(this.state.opacity, { + toValue: 1, + duration: 300 + }).start(() => { + this.props.addFileToFetchCache(Client.getFilePreviewUrl(this.props.file.id, this.state.timestamp)); + }); + } + + handleLoadStart = () => { + this.setState({ + requesting: true + }); + } + render() { const { + fetchCache, file, imageHeight, imageWidth, resizeMethod, resizeMode, + wrapperBackgroundColor, wrapperHeight, wrapperWidth } = this.props; - const source = {uri: Client.getFilePreviewUrl(file.id, this.state.timestamp)}; + const source = file.id ? {uri: Client.getFilePreviewUrl(file.id, this.state.timestamp)} : {}; + + const isInFetchCache = fetchCache[source.uri]; + const imageComponentLoaders = { + onError: isInFetchCache ? null : this.handleLoadError, + onLoadStart: isInFetchCache ? null : this.handleLoadStart, + onLoad: isInFetchCache ? null : this.handleLoad + }; + const opacity = isInFetchCache ? 1 : this.state.opacity; return ( - - + + + + + {(!isInFetchCache && (file.loading || this.state.requesting)) && + + + + } ); } @@ -83,5 +128,13 @@ const style = StyleSheet.create({ fileImageWrapper: { alignItems: 'center', justifyContent: 'center' + }, + loaderContainer: { + position: 'absolute', + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff' } }); diff --git a/app/components/file_upload_preview/file_upload_preview.js b/app/components/file_upload_preview/file_upload_preview.js index fe09207b3..74e6322c2 100644 --- a/app/components/file_upload_preview/file_upload_preview.js +++ b/app/components/file_upload_preview/file_upload_preview.js @@ -4,10 +4,8 @@ import React, {PropTypes, PureComponent} from 'react'; import { Dimensions, - KeyboardAvoidingView, ScrollView, StyleSheet, - Text, TouchableOpacity, View } from 'react-native'; @@ -15,22 +13,23 @@ import Font from 'react-native-vector-icons/FontAwesome'; import {RequestStatus} from 'mattermost-redux/constants'; import FileAttachmentPreview from 'app/components/file_attachment_list/file_attachment_preview'; +import KeyboardLayout from 'app/components/layout/keyboard_layout'; const {height: deviceHeight} = Dimensions.get('window'); export default class FileUploadPreview extends PureComponent { static propTypes = { actions: PropTypes.shape({ - cancelUploadFileRequest: PropTypes.func.isRequired, + addFileToFetchCache: PropTypes.func.isRequired, handleClearFiles: PropTypes.func.isRequired, handleRemoveFile: PropTypes.func.isRequired }).isRequired, channelId: PropTypes.string.isRequired, createPostRequestStatus: PropTypes.string.isRequired, + fetchCache: PropTypes.object.isRequired, files: PropTypes.array.isRequired, rootId: PropTypes.string, - uploadFileRequestStatus: PropTypes.string.isRequired, - uploadFileRequestId: PropTypes.number + uploadFileRequestStatus: PropTypes.string.isRequired }; componentWillReceiveProps(nextProps) { @@ -43,13 +42,17 @@ export default class FileUploadPreview extends PureComponent { return this.props.files.map((file) => { return ( - + this.props.actions.handleRemoveFile(file.id, this.props.channelId, this.props.rootId)} + onPress={() => this.props.actions.handleRemoveFile(file.clientId, this.props.channelId, this.props.rootId)} > { - this.props.actions.cancelUploadFileRequest(this.props.uploadFileRequestId); - } - render() { if (!this.props.files.length && this.props.uploadFileRequestStatus !== RequestStatus.STARTED) { return null; } - let component; - if (this.props.uploadFileRequestStatus === RequestStatus.STARTED) { - component = ( - - {'Loading...'} - - {'Cancel'} - - - ); - } else { - component = ( + return ( + {this.buildFilePreviews()} - ); - } - - return ( - - {component} - + ); } } @@ -132,7 +115,7 @@ const style = StyleSheet.create({ alignItems: 'center', height: 115, justifyContent: 'center', - marginLeft: 24, + marginRight: 5, width: 115 }, removeButton: { @@ -151,6 +134,7 @@ const style = StyleSheet.create({ marginBottom: 24 }, scrollViewContent: { - alignItems: 'flex-end' + alignItems: 'flex-end', + marginLeft: 10 } }); diff --git a/app/components/file_upload_preview/index.js b/app/components/file_upload_preview/index.js index 210077d23..beffe33ab 100644 --- a/app/components/file_upload_preview/index.js +++ b/app/components/file_upload_preview/index.js @@ -3,9 +3,10 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {cancelUploadFileRequest} from 'mattermost-redux/actions/files'; import {handleClearFiles, handleRemoveFile} from 'app/actions/views/file_upload'; +import {addFileToFetchCache} from 'app/actions/views/file_preview'; +import {getTheme} from 'app/selectors/preferences'; import FileUploadPreview from './file_upload_preview'; @@ -13,14 +14,16 @@ function mapStateToProps(state, ownProps) { return { ...ownProps, createPostRequestStatus: state.requests.posts.createPost.status, - uploadFileRequestStatus: state.requests.files.uploadFiles.status + fetchCache: state.views.fetchCache, + uploadFileRequestStatus: state.requests.files.uploadFiles.status, + theme: getTheme(state) }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - cancelUploadFileRequest, + addFileToFetchCache, handleClearFiles, handleRemoveFile }, dispatch) diff --git a/app/components/post_textbox/post_textbox.js b/app/components/post_textbox/post_textbox.js index af9c9783b..1c79bbebc 100644 --- a/app/components/post_textbox/post_textbox.js +++ b/app/components/post_textbox/post_textbox.js @@ -135,11 +135,7 @@ export default class PostTextbox extends PureComponent { } uploadFiles = (images) => { - const uploadFileRequestId = Date.now(); - this.setState({ - uploadFileRequestId - }); - this.props.actions.handleUploadFiles(images, this.props.rootId, uploadFileRequestId); + this.props.actions.handleUploadFiles(images, this.props.rootId); } showFileAttachmentOptions = () => { @@ -225,7 +221,6 @@ export default class PostTextbox extends PureComponent { channelId={this.props.channelId} files={this.props.files} rootId={this.props.rootId} - uploadFileRequestId={this.state.uploadFileRequestId} /> ({clientId: id, loading: true})); const files = [ ...state[action.channelId].files, - ...action.data + ...tempFiles ]; return { @@ -52,6 +53,26 @@ function drafts(state = {}, action) { [action.channelId]: Object.assign({}, state[action.channelId], {files}) }; } + case FilesTypes.RECEIVED_UPLOAD_FILES: { + if (action.rootId || !state[action.channelId].files) { + return state; + } + + // Reconcile tempFiles with the received uploaded files + const files = state[action.channelId].files.map((tempFile) => { + const file = action.data.find((f) => f.clientId === tempFile.clientId); + if (file) { + return file; + } + + return tempFile; + }); + + return { + ...state, + [action.channelId]: Object.assign({}, state[action.channelId], {files}) + }; + } case ViewTypes.CLEAR_FILES_FOR_POST_DRAFT: { if (action.rootId) { return state; @@ -67,7 +88,7 @@ function drafts(state = {}, action) { return state; } - const files = state[action.channelId].files.filter((file) => file.id !== action.fileId); + const files = state[action.channelId].files.filter((file) => (file.clientId !== action.clientId)); return { ...state, diff --git a/app/reducers/views/fetch_cache.js b/app/reducers/views/fetch_cache.js new file mode 100644 index 000000000..780277931 --- /dev/null +++ b/app/reducers/views/fetch_cache.js @@ -0,0 +1,16 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ViewTypes} from 'app/constants'; + +export default function fetchCache(state = {}, action) { + switch (action.type) { + case ViewTypes.ADD_FILE_TO_FETCH_CACHE: + return { + ...state, + [action.url]: true + }; + default: + return state; + } +} diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 986129e99..eba4cc4dc 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -4,6 +4,7 @@ import {combineReducers} from 'redux'; import channel from './channel'; +import fetchCache from './fetch_cache'; import i18n from './i18n'; import login from './login'; import notification from './notification'; @@ -13,6 +14,7 @@ import thread from './thread'; export default combineReducers({ channel, + fetchCache, i18n, login, notification, diff --git a/app/reducers/views/thread.js b/app/reducers/views/thread.js index a7ef55f6e..d4f6886c7 100644 --- a/app/reducers/views/thread.js +++ b/app/reducers/views/thread.js @@ -36,15 +36,36 @@ function drafts(state = {}, action) { return data; } + case ViewTypes.SET_TEMP_UPLOAD_FILES_FOR_POST_DRAFT: { + if (!action.rootId) { + return state; + } + + const tempFiles = action.clientIds.map((id) => ({clientId: id, loading: true})); + const files = [ + ...state[action.rootId].files, + ...tempFiles + ]; + + return { + ...state, + [action.rootId]: Object.assign({}, state[action.rootId], {files}) + }; + } case FilesTypes.RECEIVED_UPLOAD_FILES: { if (!action.rootId) { return state; } - const files = [ - ...state[action.rootId].files, - ...action.data - ]; + // Reconcile tempFiles with the received uploaded files + const files = state[action.rootId].files.map((tempFile) => { + const file = action.data.find((f) => f.clientId === tempFile.clientId); + if (file) { + return file; + } + + return tempFile; + }); return { ...state, diff --git a/app/scenes/image_preview/image_preview.js b/app/scenes/image_preview/image_preview.js index afe52862c..32e5155a2 100644 --- a/app/scenes/image_preview/image_preview.js +++ b/app/scenes/image_preview/image_preview.js @@ -15,7 +15,7 @@ import { TouchableWithoutFeedback, View } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; +import Icon from 'react-native-vector-icons/Ionicons'; import LinearGradient from 'react-native-linear-gradient'; import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; @@ -32,8 +32,10 @@ const STATUS_BAR_HEIGHT = Platform.OS === 'android' ? 25 : 0; // Used to account export default class ImagePreview extends PureComponent { static propTypes = { actions: PropTypes.shape({ + addFileToFetchCache: PropTypes.func.isRequired, goBack: PropTypes.func.isRequired }), + fetchCache: PropTypes.object.isRequired, fileId: PropTypes.string.isRequired, files: PropTypes.array.isRequired, theme: PropTypes.object.isRequired @@ -163,8 +165,8 @@ export default class ImagePreview extends PureComponent { style={style.headerIcon} > @@ -199,11 +201,14 @@ export default class ImagePreview extends PureComponent { if (file.has_preview_image) { component = ( @@ -281,7 +286,7 @@ const style = StyleSheet.create({ flexDirection: 'row', ...Platform.select({ ios: { - marginTop: 30 + marginTop: 20 }, android: { marginTop: 10 diff --git a/app/scenes/image_preview/index.js b/app/scenes/image_preview/index.js index da9f6be4a..9143ac867 100644 --- a/app/scenes/image_preview/index.js +++ b/app/scenes/image_preview/index.js @@ -4,6 +4,7 @@ import {bindActionCreators} from 'redux'; import {goBack} from 'app/actions/navigation'; +import {addFileToFetchCache} from 'app/actions/views/file_preview'; import {getTheme} from 'app/selectors/preferences'; import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; @@ -16,6 +17,7 @@ function makeMapStateToProps() { return function mapStateToProps(state, ownProps) { return { ...ownProps, + fetchCache: state.views.fetchCache, files: getFilesForPost(state, ownProps.post), theme: getTheme(state) }; @@ -25,6 +27,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + addFileToFetchCache, goBack }, dispatch) };