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