PLT-6033 Add loading spinner to image previewer (#387)

This commit is contained in:
Chris Duarte 2017-03-23 13:21:14 -07:00 committed by enahum
parent c049cb0f9d
commit f37c308ab8
17 changed files with 211 additions and 71 deletions

View file

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

View file

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

View file

@ -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 = (
<FileAttachmentPreview
addFileToFetchCache={this.props.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
theme={theme}
/>

View file

@ -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)}
>
<FileAttachment
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
theme={this.props.theme}
/>

View file

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

View file

@ -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 (
<View style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth}]}>
<Image
style={{height: imageHeight, width: imageWidth}}
source={source}
defaultSource={genericIcon}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
onError={this.handleError}
/>
<View style={[style.fileImageWrapper, {backgroundColor: wrapperBackgroundColor, height: wrapperHeight, width: wrapperWidth}]}>
<AnimatedView style={{height: imageHeight, width: imageWidth, backgroundColor: wrapperBackgroundColor, opacity}}>
<Image
style={{height: imageHeight, width: imageWidth}}
source={source}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageComponentLoaders}
/>
</AnimatedView>
{(!isInFetchCache && (file.loading || this.state.requesting)) &&
<View style={style.loaderContainer}>
<ActivityIndicator size='small'/>
</View>
}
</View>
);
}
@ -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'
}
});

View file

@ -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 (
<View
key={file.id}
key={file.clientId}
style={style.preview}
>
<FileAttachmentPreview file={file}/>
<FileAttachmentPreview
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
/>
<TouchableOpacity
style={style.removeButton}
onPress={() => 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)}
>
<Font
name='times'
@ -62,27 +65,13 @@ export default class FileUploadPreview extends PureComponent {
});
}
handleCancelUpload = () => {
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 = (
<View style={style.loader}>
<Text style={style.loaderText}>{'Loading...'}</Text>
<TouchableOpacity onPress={this.handleCancelUpload}>
<Text style={style.cancelText}>{'Cancel'}</Text>
</TouchableOpacity>
</View>
);
} else {
component = (
return (
<KeyboardLayout style={style.container}>
<ScrollView
horizontal={true}
style={style.scrollView}
@ -90,13 +79,7 @@ export default class FileUploadPreview extends PureComponent {
>
{this.buildFilePreviews()}
</ScrollView>
);
}
return (
<KeyboardAvoidingView style={style.container}>
{component}
</KeyboardAvoidingView>
</KeyboardLayout>
);
}
}
@ -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
}
});

View file

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

View file

@ -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}
/>
<Autocomplete
ref={this.attachAutocomplete}

View file

@ -19,9 +19,14 @@ const ViewTypes = keyMirror({
SET_POST_DRAFT: null,
SET_COMMENT_DRAFT: null,
SET_TEMP_UPLOAD_FILES_FOR_POST_DRAFT: null,
CLEAR_FILES_FOR_POST_DRAFT: null,
REMOVE_FILE_FROM_POST_DRAFT: null
REMOVE_FILE_FROM_POST_DRAFT: null,
REMOVE_LAST_FILE_FROM_POST_DRAFT: null,
ADD_FILE_TO_FETCH_CACHE: null
});
export default ViewTypes;

View file

@ -279,6 +279,7 @@ const state = {
channel: {
drafts: {}
},
fetchCache: {},
i18n: {
locale: ''
},

View file

@ -37,14 +37,15 @@ function drafts(state = {}, action) {
return data;
}
case FilesTypes.RECEIVED_UPLOAD_FILES: {
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.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,

View file

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

View file

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

View file

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

View file

@ -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}
>
<Icon
name='close'
size={15}
name='md-close'
size={26}
color='#fff'
/>
</TouchableOpacity>
@ -199,11 +201,14 @@ export default class ImagePreview extends PureComponent {
if (file.has_preview_image) {
component = (
<FileAttachmentPreview
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
theme={this.props.theme}
imageHeight={Math.min(maxImageHeight, file.height)}
imageWidth={Math.min(this.state.deviceWidth, file.width)}
resizeMode='contain'
wrapperBackgroundColor='#000'
wrapperHeight={maxImageHeight}
wrapperWidth={this.state.deviceWidth}
/>
@ -281,7 +286,7 @@ const style = StyleSheet.create({
flexDirection: 'row',
...Platform.select({
ios: {
marginTop: 30
marginTop: 20
},
android: {
marginTop: 10

View file

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