From a7dc43aee9024345b8e3c4a50761d8e4947ac5e7 Mon Sep 17 00:00:00 2001 From: enahum Date: Tue, 2 May 2017 12:06:08 -0300 Subject: [PATCH] Retry File Upload (#515) * rename searchMoreChannels to searchChannels * Handle send post when a file failed to upload --- app/actions/views/file_upload.js | 41 +++++++++++++-- .../channel_mention/channel_mention.js | 4 +- .../autocomplete/channel_mention/index.js | 4 +- .../file_attachment_image.js | 10 ++-- .../file_upload_preview.js | 36 +++++++++++-- app/components/file_upload_preview/index.js | 5 +- app/components/post_textbox/post_textbox.js | 40 ++++++++++++-- app/constants/view.js | 1 + app/reducers/views/channel.js | 45 ++++++++++++++++ app/reducers/views/thread.js | 52 ++++++++++++++++++- app/scenes/more_channels/index.js | 4 +- app/scenes/more_channels/more_channels.js | 4 +- yarn.lock | 2 +- 13 files changed, 220 insertions(+), 28 deletions(-) diff --git a/app/actions/views/file_upload.js b/app/actions/views/file_upload.js index 0017a9cbc..efface9cf 100644 --- a/app/actions/views/file_upload.js +++ b/app/actions/views/file_upload.js @@ -4,7 +4,7 @@ import FormData from 'form-data'; import {Platform} from 'react-native'; import {uploadFile} from 'mattermost-redux/actions/files'; -import {lookupMimeType} from 'mattermost-redux/utils/file_utils'; +import {lookupMimeType, parseClientIdsFromFormData} from 'mattermost-redux/utils/file_utils'; import {generateId} from 'app/utils/file'; import {ViewTypes} from 'app/constants'; @@ -23,7 +23,9 @@ export function handleUploadFiles(files, rootId) { clientIds.push({ clientId, - localPath: file.path + localPath: file.uri, + name: file.fileName, + type: mimeType }); const fileData = { @@ -49,7 +51,40 @@ export function handleUploadFiles(files, rootId) { rootId }); - await uploadFile(channelId, rootId, formData, formBoundary)(dispatch, getState); + await uploadFile(channelId, rootId, parseClientIdsFromFormData(formData), formData, formBoundary)(dispatch, getState); + }; +} + +export function retryFileUpload(file, rootId) { + return async (dispatch, getState) => { + const state = getState(); + + const channelId = state.entities.channels.currentChannelId; + const formData = new FormData(); + + const fileData = { + uri: file.localPath, + name: file.name, + type: file.type + }; + + formData.append('files', fileData); + formData.append('channel_id', channelId); + formData.append('client_ids', file.clientId); + + let formBoundary; + if (Platform.os === 'ios') { + formBoundary = '--mobile.client.file.upload'; + } + + dispatch({ + type: ViewTypes.RETRY_UPLOAD_FILE_FOR_POST, + clientId: file.clientId, + channelId, + rootId + }); + + await uploadFile(channelId, rootId, [file.clientId], formData, formBoundary)(dispatch, getState); }; } diff --git a/app/components/autocomplete/channel_mention/channel_mention.js b/app/components/autocomplete/channel_mention/channel_mention.js index f8612bbff..07e6b5612 100644 --- a/app/components/autocomplete/channel_mention/channel_mention.js +++ b/app/components/autocomplete/channel_mention/channel_mention.js @@ -28,7 +28,7 @@ export default class ChannelMention extends Component { theme: PropTypes.object.isRequired, onChangeText: PropTypes.func.isRequired, actions: PropTypes.shape({ - searchMoreChannels: PropTypes.func.isRequired + searchChannels: PropTypes.func.isRequired }) }; @@ -96,7 +96,7 @@ export default class ChannelMention extends Component { }); const {currentTeamId} = this.props; - this.props.actions.searchMoreChannels(currentTeamId, matchTerm); + this.props.actions.searchChannels(currentTeamId, matchTerm); return; } diff --git a/app/components/autocomplete/channel_mention/index.js b/app/components/autocomplete/channel_mention/index.js index 025590766..09ee79742 100644 --- a/app/components/autocomplete/channel_mention/index.js +++ b/app/components/autocomplete/channel_mention/index.js @@ -4,7 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {searchMoreChannels} from 'mattermost-redux/actions/channels'; +import {searchChannels} from 'mattermost-redux/actions/channels'; import {General} from 'mattermost-redux/constants'; import {getMyChannels, getOtherChannels} from 'mattermost-redux/selectors/entities/channels'; @@ -41,7 +41,7 @@ function mapStateToProps(state, ownProps) { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - searchMoreChannels + searchChannels }, dispatch) }; } diff --git a/app/components/file_attachment_list/file_attachment_image.js b/app/components/file_attachment_list/file_attachment_image.js index 9456e1c58..2184973fe 100644 --- a/app/components/file_attachment_list/file_attachment_image.js +++ b/app/components/file_attachment_list/file_attachment_image.js @@ -136,6 +136,8 @@ export default class FileAttachmentImage extends PureComponent { source = imageIcon; } else if (file.id) { source = {uri: this.handleGetImageURL()}; + } else if (file.failed) { + source = {uri: file.localPath}; } const isInFetchCache = fetchCache[source.uri]; @@ -158,10 +160,10 @@ export default class FileAttachmentImage extends PureComponent { {...imageComponentLoaders} /> - {(!isInFetchCache && (file.loading || this.state.requesting)) && - - - + {(!isInFetchCache && !file.failed && (file.loading || this.state.requesting)) && + + + } ); diff --git a/app/components/file_upload_preview/file_upload_preview.js b/app/components/file_upload_preview/file_upload_preview.js index 3ac1e11e4..73508753f 100644 --- a/app/components/file_upload_preview/file_upload_preview.js +++ b/app/components/file_upload_preview/file_upload_preview.js @@ -10,7 +10,7 @@ import { TouchableOpacity, View } from 'react-native'; -import Font from 'react-native-vector-icons/Ionicons'; +import Icon from 'react-native-vector-icons/Ionicons'; import {RequestStatus} from 'mattermost-redux/constants'; import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image'; @@ -22,7 +22,8 @@ export default class FileUploadPreview extends PureComponent { static propTypes = { actions: PropTypes.shape({ addFileToFetchCache: PropTypes.func.isRequired, - handleRemoveFile: PropTypes.func.isRequired + handleRemoveFile: PropTypes.func.isRequired, + retryFileUpload: PropTypes.func.isRequired }).isRequired, channelId: PropTypes.string.isRequired, channelIsLoading: PropTypes.bool, @@ -34,6 +35,14 @@ export default class FileUploadPreview extends PureComponent { uploadFileRequestStatus: PropTypes.string.isRequired }; + handleRetryFileUpload = (file) => { + if (!file.failed) { + return; + } + + this.props.actions.retryFileUpload(file, this.props.rootId); + }; + buildFilePreviews = () => { return this.props.files.map((file) => { return ( @@ -47,12 +56,24 @@ export default class FileUploadPreview extends PureComponent { fetchCache={this.props.fetchCache} file={file} /> + {file.failed && + this.handleRetryFileUpload(file)} + > + + + } this.props.actions.handleRemoveFile(file.clientId, this.props.channelId, this.props.rootId)} > - 0 && valueLength <= MAX_MESSAGE_LENGTH) || files.length > 0) && uploadFileRequestStatus !== RequestStatus.STARTED; + const canSend = ( + (valueLength > 0 && valueLength <= MAX_MESSAGE_LENGTH) || + files.filter((f) => !f.failed).length > 0 + ) && uploadFileRequestStatus !== RequestStatus.STARTED; this.setState({ canSend }); @@ -134,11 +137,38 @@ class PostTextbox extends PureComponent { return false; }; - sendMessage = () => { + handleSendMessage = () => { if (!this.state.canSend) { return; } + const hasFailedImages = this.props.files.some((f) => f.failed); + if (hasFailedImages) { + const {intl} = this.props; + + Alert.alert( + intl.formatMessage({ + id: 'mobile.post_textbox.uploadFailedTitle', + defaultMessage: 'Attachment failure' + }), + intl.formatMessage({ + id: 'mobile.post_textbox.uploadFailedDesc', + defaultMessage: 'Some attachments failed to upload to the server, Are you sure you want to post the message?' + }), + [{ + text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}) + }, { + text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), + onPress: this.sendMessage + }], + ); + } else { + this.sendMessage(); + } + }; + + sendMessage = () => { + const files = this.props.files.filter((f) => !f.failed); const post = { user_id: this.props.currentUserId, channel_id: this.props.channelId, @@ -147,7 +177,7 @@ class PostTextbox extends PureComponent { message: this.props.value }; - this.props.actions.createPost(post, this.props.files); + this.props.actions.createPost(post, files); this.handleTextChange(''); if (this.props.files.length) { this.props.actions.handleClearFiles(this.props.channelId, this.props.rootId); @@ -335,14 +365,14 @@ class PostTextbox extends PureComponent { onContentSizeChange={this.handleContentSizeChange} placeholder={placeholder} placeholderTextColor={changeOpacity('#000', 0.5)} - onSubmitEditing={this.sendMessage} + onSubmitEditing={this.handleSendMessage} multiline={true} underlineColorAndroid='transparent' style={[style.input, {height: Math.min(this.state.contentHeight, MAX_CONTENT_HEIGHT)}]} /> {this.state.canSend && { + if (f.clientId === action.clientId) { + return { + ...f, + loading: true, + failed: false + }; + } + + return f; + }); + + return { + ...state, + [action.channelId]: Object.assign({}, state[action.channelId], {files}) + }; + } case FileTypes.RECEIVED_UPLOAD_FILES: { if (action.rootId || !state[action.channelId].files) { return state; @@ -76,6 +98,29 @@ function drafts(state = {}, action) { [action.channelId]: Object.assign({}, state[action.channelId], {files}) }; } + case FileTypes.UPLOAD_FILES_FAILURE: { + if (action.rootId) { + return state; + } + + const clientIds = action.clientIds; + const files = state[action.channelId].files.map((tempFile) => { + if (clientIds.includes(tempFile.clientId)) { + return { + ...tempFile, + loading: false, + failed: true + }; + } + + return tempFile; + }); + + return { + ...state, + [action.channelId]: Object.assign({}, state[action.channelId], {files}) + }; + } case ViewTypes.CLEAR_FILES_FOR_POST_DRAFT: { if (action.rootId) { return state; diff --git a/app/reducers/views/thread.js b/app/reducers/views/thread.js index f894a093d..10ca589e6 100644 --- a/app/reducers/views/thread.js +++ b/app/reducers/views/thread.js @@ -41,7 +41,7 @@ function drafts(state = {}, action) { return state; } - const tempFiles = action.clientIds.map((id) => ({clientId: id, loading: true})); + const tempFiles = action.clientIds.map((temp) => ({...temp, loading: true})); const files = [ ...state[action.rootId].files, ...tempFiles @@ -52,6 +52,28 @@ function drafts(state = {}, action) { [action.rootId]: Object.assign({}, state[action.rootId], {files}) }; } + case ViewTypes.RETRY_UPLOAD_FILE_FOR_POST: { + if (!action.rootId) { + return state; + } + + const files = state[action.rootId].files.map((f) => { + if (f.clientId === action.clientId) { + return { + ...f, + loading: true, + failed: false + }; + } + + return f; + }); + + return { + ...state, + [action.rootId]: Object.assign({}, state[action.rootId], {files}) + }; + } case FileTypes.RECEIVED_UPLOAD_FILES: { if (!action.rootId) { return state; @@ -61,7 +83,33 @@ function drafts(state = {}, action) { const files = state[action.rootId].files.map((tempFile) => { const file = action.data.find((f) => f.clientId === tempFile.clientId); if (file) { - return file; + return { + ...file, + localPath: tempFile.localPath + }; + } + + return tempFile; + }); + + return { + ...state, + [action.rootId]: Object.assign({}, state[action.rootId], {files}) + }; + } + case FileTypes.UPLOAD_FILES_FAILURE: { + if (!action.rootId) { + return state; + } + + const clientIds = action.clientIds; + const files = state[action.rootId].files.map((tempFile) => { + if (clientIds.includes(tempFile.clientId)) { + return { + ...tempFile, + loading: false, + failed: true + }; } return tempFile; diff --git a/app/scenes/more_channels/index.js b/app/scenes/more_channels/index.js index 10e6a5b07..884427ae8 100644 --- a/app/scenes/more_channels/index.js +++ b/app/scenes/more_channels/index.js @@ -9,7 +9,7 @@ import {closeDrawers, goBack, goToCreateChannel} from 'app/actions/navigation'; import {getTheme} from 'app/selectors/preferences'; import {getOtherChannels} from 'mattermost-redux/selectors/entities/channels'; import {handleSelectChannel} from 'app/actions/views/channel'; -import {getChannels, joinChannel, searchMoreChannels} from 'mattermost-redux/actions/channels'; +import {getChannels, joinChannel, searchChannels} from 'mattermost-redux/actions/channels'; import MoreChannels from './more_channels'; @@ -37,7 +37,7 @@ function mapDispatchToProps(dispatch) { goToCreateChannel, joinChannel, getChannels, - searchMoreChannels + searchChannels }, dispatch) }; } diff --git a/app/scenes/more_channels/more_channels.js b/app/scenes/more_channels/more_channels.js index 7628d3e2b..b203a23a5 100644 --- a/app/scenes/more_channels/more_channels.js +++ b/app/scenes/more_channels/more_channels.js @@ -41,7 +41,7 @@ class MoreChannels extends PureComponent { goToCreateChannel: PropTypes.func.isRequired, joinChannel: PropTypes.func.isRequired, getChannels: PropTypes.func.isRequired, - searchMoreChannels: PropTypes.func.isRequired + searchChannels: PropTypes.func.isRequired }).isRequired }; @@ -151,7 +151,7 @@ class MoreChannels extends PureComponent { clearTimeout(this.searchTimeoutId); this.searchTimeoutId = setTimeout(() => { - this.props.actions.searchMoreChannels(this.props.currentTeamId, term); + this.props.actions.searchChannels(this.props.currentTeamId, term); }, General.SEARCH_TIMEOUT_MILLISECONDS); } else { this.cancelSearch(); diff --git a/yarn.lock b/yarn.lock index cf4965fac..1ca7ed6ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3518,7 +3518,7 @@ makeerror@1.0.x: mattermost-redux@mattermost/mattermost-redux#master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/e75da057d1b37551ed4c4a4d8edf4f301c434162" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/e24060513e9f95c68c55bd0a1bd432259c79cf18" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1"