RN-52: RN: Make create post and upload file actions optimistic (#504)

* RN-52: RN: Make create post and upload file actions optimistic

* Review feedback
This commit is contained in:
Chris Duarte 2017-04-27 04:45:42 -07:00 committed by enahum
parent 43bdf33c03
commit 9d13c985c2
16 changed files with 249 additions and 104 deletions

View file

@ -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,

View file

@ -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 (
<View>
<Text
@ -66,7 +70,7 @@ export default class FileAttachment extends PureComponent {
const style = getStyleSheet(theme);
let fileAttachmentComponent;
if (file.has_preview_image) {
if (file.has_preview_image || file.loading) {
fileAttachmentComponent = (
<FileAttachmentImage
addFileToFetchCache={this.props.addFileToFetchCache}

View file

@ -101,6 +101,10 @@ export default class FileAttachmentImage extends PureComponent {
handleGetImageURL = () => {
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);

View file

@ -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) => (
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
onPressIn={() => 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) => (
<FileAttachment
key={id}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
file={{loading: true}}
theme={this.props.theme}
/>
</TouchableOpacity>
));
));
} else {
fileAttachments = files.map((file) => (
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
onPressIn={() => this.props.toggleSelected(true)}
onPressOut={() => this.props.toggleSelected(false)}
>
<FileAttachment
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
theme={this.props.theme}
/>
</TouchableOpacity>
));
}
return (
<View style={{flex: 1}}>
<View style={[{flex: 1}, (post.failed && {opacity: 0.5})]}>
{fileAttachments}
</View>
);

View file

@ -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
};
};
}

View file

@ -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}
>
<FileAttachmentImage
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
/>
<View style={style.previewShadow}>
<FileAttachmentImage
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
/>
</View>
<TouchableOpacity
style={style.removeButtonWrapper}
onPress={() => this.props.actions.handleRemoveFile(file.clientId, this.props.channelId, this.props.rootId)}
@ -75,14 +70,16 @@ export default class FileUploadPreview extends PureComponent {
}
return (
<KeyboardLayout style={style.container}>
<ScrollView
horizontal={true}
style={style.scrollView}
contentContainerStyle={[style.scrollViewContent, {marginBottom: this.props.inputHeight}]}
>
{this.buildFilePreviews()}
</ScrollView>
<KeyboardLayout>
<View style={[style.container]}>
<ScrollView
horizontal={true}
style={style.scrollView}
contentContainerStyle={style.scrollViewContent}
>
{this.buildFilePreviews()}
</ScrollView>
</View>
</KeyboardLayout>
);
}
@ -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',

View file

@ -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)
};

View file

@ -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)
};

View file

@ -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 = (
<Markdown
baseTextStyle={messageStyle}
textStyles={getMarkdownTextStyles(theme)}
blockStyles={getMarkdownBlockStyles(theme)}
value={post.message}
/>
<View style={{flexDirection: 'row'}}>
<View style={[{flex: 1}, (post.failed && style.failedPost)]}>
<Markdown
baseTextStyle={messageStyle}
textStyles={getMarkdownTextStyles(theme)}
blockStyles={getMarkdownBlockStyles(theme)}
value={post.message}
/>
</View>
</View>
);
}
@ -273,16 +318,30 @@ class Post extends PureComponent {
messageContainer = (
<View style={style.messageContainerWithReplyBar}>
{replyBar && this.renderReplyBar(style)}
<View style={{flex: 1}}>
<OptionsContext
actions={actions}
ref='tooltip'
onPress={this.handlePress}
toggleSelected={this.toggleSelected}
>
{message}
{this.renderFileAttachments()}
</OptionsContext>
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{flex: 1}}>
<OptionsContext
actions={actions}
ref='tooltip'
onPress={this.handlePress}
toggleSelected={this.toggleSelected}
>
{message}
{this.renderFileAttachments()}
</OptionsContext>
</View>
{post.failed &&
<TouchableOpacity
onPress={this.handleFailedPostPress}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableOpacity>
}
</View>
</View>
);
@ -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'}}
>
<View style={{flex: 1}}>
{message}
<OptionsContext
ref='bottomSheet'
actions={actions}
cancelText={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
/>
{this.renderFileAttachments()}
<View style={{flexDirection: 'row', flex: 1}}>
<View style={{flex: 1}}>
{message}
<OptionsContext
ref='bottomSheet'
actions={actions}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
/>
{this.renderFileAttachments()}
</View>
{post.failed &&
<TouchableOpacity
onPress={this.handleFailedPostPress}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableOpacity>
}
</View>
</TouchableHighlight>
</View>
@ -432,11 +505,11 @@ class Post extends PureComponent {
if (this.props.commentedOnPost) {
contents = (
<View style={[style.container, this.props.style, selected]}>
<View style={style.profilePictureContainer}>
<View style={[style.profilePictureContainer, (post.failed && style.failedPost)]}>
{profilePicture}
</View>
<View style={style.rightColumn}>
<View style={style.postInfoContainer}>
<View style={[style.postInfoContainer, (post.failed && style.failedPost)]}>
{displayName}
<Text style={style.time}>
<FormattedTime value={this.props.post.create_at}/>
@ -452,13 +525,13 @@ class Post extends PureComponent {
} else {
contents = (
<View style={[style.container, this.props.style, selected]}>
<View style={style.profilePictureContainer}>
<View style={[style.profilePictureContainer, (post.failed && style.failedPost)]}>
{profilePicture}
</View>
<View style={style.messageContainerWithReplyBar}>
{this.renderReplyBar(style)}
<View style={[style.rightColumn, (this.props.isLastReply && style.rightColumnPadding)]}>
<View style={style.postInfoContainer}>
<View style={[style.postInfoContainer, (post.failed && style.failedPost)]}>
<View style={{flexDirection: 'row', flex: 1}}>
{displayName}
<Text style={style.time}>
@ -497,6 +570,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row'
},
failedPost: {
opacity: 0.5
},
rightColumn: {
flex: 1,
flexDirection: 'column',

View file

@ -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) {

View file

@ -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,

View file

@ -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}
/>
<View style={{backgroundColor: theme.centerChannelBg}}>
<View style={{backgroundColor: theme.centerChannelBg, elevation: 5}}>
<View
style={style.inputWrapper}
>

View file

@ -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;

View file

@ -59,7 +59,11 @@ export default class OptionsModalList extends PureComponent {
onPress={() => preventDoubleTap(onCancelPress, this)}
style={style.option}
>
<Text style={style.optionText}>{'Cancel'}</Text>
<FormattedText
id='channel_modal.cancel'
defaultMessage='Cancel'
style={style.optionText}
/>
</TouchableOpacity>
);

View file

@ -100,7 +100,11 @@ export default class OptionsModalList extends PureComponent {
onPress={() => preventDoubleTap(onCancelPress, this)}
style={style.option}
>
<Text style={style.optionCancelText}>{'Cancel'}</Text>
<FormattedText
id='channel_modal.cancel'
defaultMessage='Cancel'
style={style.optionCancelText}
/>
</TouchableOpacity>
</View>
</View>

View file

@ -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 ",