diff --git a/app/actions/views/file_upload.js b/app/actions/views/file_upload.js index b90716b93..6003fc547 100644 --- a/app/actions/views/file_upload.js +++ b/app/actions/views/file_upload.js @@ -22,7 +22,10 @@ export function handleUploadFiles(files, rootId) { const mimeType = lookupMimeType(name); const clientId = generateId(); - clientIds.push(clientId); + clientIds.push({ + clientId, + localPath: file.path + }); const fileData = { uri: file.path, diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index d54f99c3b..32e443824 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -35,6 +35,10 @@ export default class FileAttachment extends PureComponent { const {file, theme} = this.props; const style = getStyleSheet(theme); + if (!file.id) { + return null; + } + return ( { const {file, imageSize} = this.props; + if (file.localPath && this.state.retry === 0) { + return file.localPath; + } + switch (imageSize) { case IMAGE_SIZE.Fullsize: return Client.getFileUrl(file.id, this.state.timestamp); diff --git a/app/components/file_attachment_list/file_attachment_list.js b/app/components/file_attachment_list/file_attachment_list.js index 769f20ecc..3ef85a66a 100644 --- a/app/components/file_attachment_list/file_attachment_list.js +++ b/app/components/file_attachment_list/file_attachment_list.js @@ -10,6 +10,7 @@ import { View, TouchableOpacity } from 'react-native'; +import {RequestStatus} from 'mattermost-redux/constants'; import {preventDoubleTap} from 'app/utils/tap'; import FileAttachment from './file_attachment'; @@ -24,7 +25,8 @@ export default class FileAttachmentList extends Component { onPress: PropTypes.func, post: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, - toggleSelected: PropTypes.func.isRequired + toggleSelected: PropTypes.func.isRequired, + filesForPostRequest: PropTypes.object.isRequired }; componentDidMount() { @@ -32,6 +34,15 @@ export default class FileAttachmentList extends Component { this.props.actions.loadFilesForPostIfNecessary(post); } + componentDidUpdate() { + const {files, filesForPostRequest, post} = this.props; + + // Fixes an issue where files weren't loading with optimistic post + if (!files.length && post.file_ids.length > 0 && filesForPostRequest.status !== RequestStatus.STARTED) { + this.props.actions.loadFilesForPostIfNecessary(post); + } + } + handleInfoPress = () => { this.props.hideOptionsContext(); this.props.onPress(); @@ -43,26 +54,41 @@ export default class FileAttachmentList extends Component { }; render() { - const fileAttachments = this.props.files.map((file) => ( - this.props.toggleSelected(true)} - onPressOut={() => this.props.toggleSelected(false)} - > + const {files, post} = this.props; + + let fileAttachments; + if (!files.length && post.file_ids.length > 0) { + fileAttachments = post.file_ids.map((id) => ( - - )); + )); + } else { + fileAttachments = files.map((file) => ( + this.props.toggleSelected(true)} + onPressOut={() => this.props.toggleSelected(false)} + > + + + )); + } return ( - + {fileAttachments} ); diff --git a/app/components/file_attachment_list/index.js b/app/components/file_attachment_list/index.js index cee7d8c31..b1ffbcd91 100644 --- a/app/components/file_attachment_list/index.js +++ b/app/components/file_attachment_list/index.js @@ -19,7 +19,8 @@ function makeMapStateToProps() { ...ownProps, fetchCache: state.views.fetchCache, files: getFilesForPost(state, ownProps.post), - theme: getTheme(state) + theme: getTheme(state), + filesForPostRequest: state.requests.files.getFilesForPost }; }; } diff --git a/app/components/file_upload_preview/file_upload_preview.js b/app/components/file_upload_preview/file_upload_preview.js index f75ba5e67..3ac1e11e4 100644 --- a/app/components/file_upload_preview/file_upload_preview.js +++ b/app/components/file_upload_preview/file_upload_preview.js @@ -22,7 +22,6 @@ export default class FileUploadPreview extends PureComponent { static propTypes = { actions: PropTypes.shape({ addFileToFetchCache: PropTypes.func.isRequired, - handleClearFiles: PropTypes.func.isRequired, handleRemoveFile: PropTypes.func.isRequired }).isRequired, channelId: PropTypes.string.isRequired, @@ -35,12 +34,6 @@ export default class FileUploadPreview extends PureComponent { uploadFileRequestStatus: PropTypes.string.isRequired }; - componentWillReceiveProps(nextProps) { - if (this.props.createPostRequestStatus === RequestStatus.STARTED && nextProps.createPostRequestStatus === RequestStatus.SUCCESS) { - this.props.actions.handleClearFiles(this.props.channelId, this.props.rootId); - } - } - buildFilePreviews = () => { return this.props.files.map((file) => { return ( @@ -48,11 +41,13 @@ export default class FileUploadPreview extends PureComponent { key={file.clientId} style={style.preview} > - + + + this.props.actions.handleRemoveFile(file.clientId, this.props.channelId, this.props.rootId)} @@ -75,14 +70,16 @@ export default class FileUploadPreview extends PureComponent { } return ( - - - {this.buildFilePreviews()} - + + + + {this.buildFilePreviews()} + + ); } @@ -100,15 +97,23 @@ const style = StyleSheet.create({ preview: { justifyContent: 'flex-end', height: 115, - width: 115, + width: 115 + }, + previewShadow: { + width: 100, elevation: 10, - shadowColor: '#000', - shadowOpacity: 0.5, - shadowRadius: 4, - shadowOffset: { - width: 0, - height: 0 - } + ...Platform.select({ + ios: { + backgroundColor: '#fff', + shadowColor: '#000', + shadowOpacity: 0.5, + shadowRadius: 4, + shadowOffset: { + width: 0, + height: 0 + } + } + }) }, removeButtonIcon: Platform.select({ ios: { @@ -123,6 +128,7 @@ const style = StyleSheet.create({ justifyContent: 'center', position: 'absolute', overflow: 'hidden', + elevation: 11, top: 7, right: 7, width: 24, @@ -134,7 +140,7 @@ const style = StyleSheet.create({ }, scrollView: { flex: 1, - marginBottom: 24 + marginBottom: 12 }, scrollViewContent: { alignItems: 'flex-end', diff --git a/app/components/file_upload_preview/index.js b/app/components/file_upload_preview/index.js index 809e72c39..ed44d1108 100644 --- a/app/components/file_upload_preview/index.js +++ b/app/components/file_upload_preview/index.js @@ -4,7 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {handleClearFiles, handleRemoveFile} from 'app/actions/views/file_upload'; +import {handleRemoveFile} from 'app/actions/views/file_upload'; import {addFileToFetchCache} from 'app/actions/views/file_preview'; import {getTheme} from 'app/selectors/preferences'; @@ -25,7 +25,6 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ addFileToFetchCache, - handleClearFiles, handleRemoveFile }, dispatch) }; diff --git a/app/components/post/index.js b/app/components/post/index.js index 8db11c3d3..f5dfb7ed8 100644 --- a/app/components/post/index.js +++ b/app/components/post/index.js @@ -3,14 +3,14 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {deletePost, flagPost, removePost, unflagPost} from 'mattermost-redux/actions/posts'; +import {createPost, deletePost, flagPost, removePost, unflagPost} from 'mattermost-redux/actions/posts'; import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {makeGetCommentCountForPost} from 'mattermost-redux/selectors/entities/posts'; import {getCurrentUserId, getCurrentUserRoles, getUser} from 'mattermost-redux/selectors/entities/users'; import {isPostFlagged} from 'mattermost-redux/utils/post_utils'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; -import {goToUserProfile, openEditPostModal} from 'app/actions/navigation'; +import {goToUserProfile, openEditPostModal, requestCloseModal, showOptionsModal} from 'app/actions/navigation'; import {getTheme} from 'app/selectors/preferences'; import Post from './post'; @@ -43,11 +43,14 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + createPost, deletePost, flagPost, goToUserProfile, openEditPostModal, removePost, + requestCloseModal, + showOptionsModal, unflagPost }, dispatch) }; diff --git a/app/components/post/post.js b/app/components/post/post.js index 193b425fb..0f43d0899 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -13,6 +13,7 @@ import { View } from 'react-native'; import {injectIntl, intlShape} from 'react-intl'; +import Icon from 'react-native-vector-icons/Ionicons'; import FileAttachmentList from 'app/components/file_attachment_list'; import FormattedText from 'app/components/formatted_text'; @@ -55,12 +56,15 @@ class Post extends PureComponent { theme: PropTypes.object.isRequired, onPress: PropTypes.func, actions: PropTypes.shape({ + createPost: PropTypes.func.isRequired, deletePost: PropTypes.func.isRequired, flagPost: PropTypes.func.isRequired, goToUserProfile: PropTypes.func.isRequired, openEditPostModal: PropTypes.func.isRequired, removePost: PropTypes.func.isRequired, - unflagPost: PropTypes.func.isRequired + unflagPost: PropTypes.func.isRequired, + requestCloseModal: PropTypes.func.isRequired, + showOptionsModal: PropTypes.func.isRequired }).isRequired }; @@ -117,9 +121,44 @@ class Post extends PureComponent { actions.openEditPostModal(post); }; + handleFailedPostPress = () => { + const options = { + title: { + id: 'mobile.post.failed_title', + defaultMessage: 'Unable to send your message:' + }, + items: [{ + action: () => { + const {failed, id, ...post} = this.props.post; // eslint-disable-line + + this.props.actions.requestCloseModal(); + this.props.actions.createPost(post); + }, + text: { + id: 'mobile.post.failed_retry', + defaultMessage: 'Try Again' + } + }, { + action: () => { + this.props.actions.requestCloseModal(); + this.onRemovePost(this.props.post); + }, + text: { + id: 'mobile.post.failed_delete', + defaultMessage: 'Delete Message' + }, + textStyle: { + color: '#CC3239' + } + }] + }; + + this.props.actions.showOptionsModal(options); + }; + handlePress = () => { const {post, onPress} = this.props; - if (onPress && post.state !== Posts.POST_DELETED && !isSystemMessage(post)) { + if (onPress && post.state !== Posts.POST_DELETED && !isSystemMessage(post) && !post.failed) { preventDoubleTap(onPress, null, post); } else if (post.state === Posts.POST_DELETED) { preventDoubleTap(this.onRemovePost, this, post); @@ -228,24 +267,26 @@ class Post extends PureComponent { const actions = []; // we should check for the user roles and permissions - if (isFlagged) { - actions.push({ - text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}), - onPress: () => unflagPost(post.id) - }); - } else { - actions.push({ - text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}), - onPress: () => flagPost(post.id) - }); - } + if (!post.failed) { + if (isFlagged) { + actions.push({ + text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}), + onPress: () => unflagPost(post.id) + }); + } else { + actions.push({ + text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}), + onPress: () => flagPost(post.id) + }); + } - if (this.state.canEdit) { - actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: () => this.handlePostEdit()}); - } + if (this.state.canEdit) { + actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: () => this.handlePostEdit()}); + } - if (this.state.canDelete && post.state !== Posts.POST_DELETED) { - actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: () => this.handlePostDelete()}); + if (this.state.canDelete && post.state !== Posts.POST_DELETED) { + actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: () => this.handlePostDelete()}); + } } let messageContainer; @@ -260,12 +301,16 @@ class Post extends PureComponent { ); } else if (this.props.post.message.length) { message = ( - + + + + + ); } @@ -273,16 +318,30 @@ class Post extends PureComponent { messageContainer = ( {replyBar && this.renderReplyBar(style)} - - - {message} - {this.renderFileAttachments()} - + + + + {message} + {this.renderFileAttachments()} + + + {post.failed && + + + + } ); @@ -296,16 +355,30 @@ class Post extends PureComponent { onPress={this.handlePress} onShowUnderlay={() => this.toggleSelected(true)} underlayColor='transparent' - style={{flex: 1}} + style={{flex: 1, flexDirection: 'row'}} > - - {message} - - {this.renderFileAttachments()} + + + {message} + + {this.renderFileAttachments()} + + {post.failed && + + + + } @@ -432,11 +505,11 @@ class Post extends PureComponent { if (this.props.commentedOnPost) { contents = ( - + {profilePicture} - + {displayName} @@ -452,13 +525,13 @@ class Post extends PureComponent { } else { contents = ( - + {profilePicture} {this.renderReplyBar(style)} - + {displayName} @@ -497,6 +570,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { backgroundColor: theme.centerChannelBg, flexDirection: 'row' }, + failedPost: { + opacity: 0.5 + }, rightColumn: { flex: 1, flexDirection: 'column', diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index c7a4f84a3..8ef7a427f 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -35,6 +35,10 @@ export default class PostList extends Component { lastViewedAt: PropTypes.number }; + static defaultProps = { + channel: {} + }; + constructor(props) { super(props); this.state = { @@ -76,7 +80,7 @@ export default class PostList extends Component { renderChannelIntro = () => { const {channel, channelIsLoading, posts} = this.props; - if (channel) { + if (channel.hasOwnProperty('id')) { const firstPostHasRendered = channel.total_msg_count ? posts.length > 0 : true; const messageCount = channel.total_msg_count - posts.length; if (channelIsLoading || !firstPostHasRendered || messageCount > Posts.POST_CHUNK_SIZE) { diff --git a/app/components/post_textbox/index.js b/app/components/post_textbox/index.js index f3f4b72f0..45385c00e 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_textbox/index.js @@ -7,7 +7,7 @@ import {createPost} from 'mattermost-redux/actions/posts'; import {userTyping} from 'mattermost-redux/actions/websocket'; import {showOptionsModal, requestCloseModal} from 'app/actions/navigation'; -import {handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload'; +import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload'; import {getTheme} from 'app/selectors/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getUsersTyping} from 'mattermost-redux/selectors/entities/typing'; @@ -30,6 +30,7 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ createPost, closeModal: requestCloseModal, + handleClearFiles, handleRemoveLastFile, handleUploadFiles, showOptionsModal, diff --git a/app/components/post_textbox/post_textbox.js b/app/components/post_textbox/post_textbox.js index 431b3b978..fdb379fe3 100644 --- a/app/components/post_textbox/post_textbox.js +++ b/app/components/post_textbox/post_textbox.js @@ -32,6 +32,7 @@ class PostTextbox extends PureComponent { actions: PropTypes.shape({ closeModal: PropTypes.func.isRequired, createPost: PropTypes.func.isRequired, + handleClearFiles: PropTypes.func.isRequired, handleRemoveLastFile: PropTypes.func.isRequired, handleUploadFiles: PropTypes.func.isRequired, showOptionsModal: PropTypes.func.isRequired, @@ -148,6 +149,9 @@ class PostTextbox extends PureComponent { this.props.actions.createPost(post, this.props.files); this.handleTextChange(''); + if (this.props.files.length) { + this.props.actions.handleClearFiles(this.props.channelId, this.props.rootId); + } }; handleTextChange = (text) => { @@ -298,7 +302,7 @@ class PostTextbox extends PureComponent { onChangeText={this.props.onChangeText} rootId={this.props.rootId} /> - + diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index a1eb5a28e..5a07cd1dd 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -42,7 +42,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.channelId].files, ...tempFiles @@ -62,7 +62,10 @@ function drafts(state = {}, action) { const files = state[action.channelId].files.map((tempFile) => { const file = action.data.find((f) => f.clientId === tempFile.clientId); if (file) { - return file; + return { + ...file, + localPath: tempFile.localPath + }; } return tempFile; diff --git a/app/scenes/options_modal/options_modal_list.android.js b/app/scenes/options_modal/options_modal_list.android.js index 850769ebd..66a250b0d 100644 --- a/app/scenes/options_modal/options_modal_list.android.js +++ b/app/scenes/options_modal/options_modal_list.android.js @@ -59,7 +59,11 @@ export default class OptionsModalList extends PureComponent { onPress={() => preventDoubleTap(onCancelPress, this)} style={style.option} > - {'Cancel'} + ); diff --git a/app/scenes/options_modal/options_modal_list.ios.js b/app/scenes/options_modal/options_modal_list.ios.js index ec8f3917d..c9c7f3ee9 100644 --- a/app/scenes/options_modal/options_modal_list.ios.js +++ b/app/scenes/options_modal/options_modal_list.ios.js @@ -100,7 +100,11 @@ export default class OptionsModalList extends PureComponent { onPress={() => preventDoubleTap(onCancelPress, this)} style={style.option} > - {'Cancel'} + diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1b9a629c0..3a0529c13 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1707,6 +1707,9 @@ "mobile.post.delete_question": "Are you sure you want to delete this post?", "mobile.post.delete_title": "Delete Post", "mobile.post.retry": "Refresh", + "mobile.post.failed_delete": "Delete Message", + "mobile.post.failed_retry": "Try Again", + "mobile.post.failed_title": "Unable to send your message", "mobile.request.invalid_response": "Received invalid response from the server.", "mobile.routes.channelInfo": "Info", "mobile.routes.channelInfo.createdBy": "Created by {creator} on ",