From 46a59252b655a9c2e05febd6b5e948bb5dbf81fb Mon Sep 17 00:00:00 2001 From: hmhealey Date: Fri, 18 Dec 2015 12:14:15 -0500 Subject: Refactored ViewImage modal and made it automatically play animated gifs --- web/react/components/view_image.jsx | 356 ++++++++++++++----------------- web/react/stores/file_store.jsx | 60 ++++++ web/react/utils/async_client.jsx | 28 +++ web/react/utils/client.jsx | 4 +- web/react/utils/constants.jsx | 1 + web/sass-files/sass/partials/_files.scss | 5 + 6 files changed, 254 insertions(+), 200 deletions(-) create mode 100644 web/react/stores/file_store.jsx (limited to 'web') diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 820f8fd8e..6deabff8d 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -1,9 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import * as AsyncClient from '../utils/async_client.jsx'; import * as Client from '../utils/client.jsx'; import * as Utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; +import FileStore from '../stores/file_store.jsx'; import ViewImagePopoverBar from './view_image_popover_bar.jsx'; const Modal = ReactBootstrap.Modal; const KeyCodes = Constants.KeyCodes; @@ -12,80 +14,90 @@ export default class ViewImageModal extends React.Component { constructor(props) { super(props); - this.canSetState = false; - + this.showImage = this.showImage.bind(this); this.loadImage = this.loadImage.bind(this); + this.handleNext = this.handleNext.bind(this); this.handlePrev = this.handlePrev.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); - this.getPublicLink = this.getPublicLink.bind(this); - this.getPreviewImagePath = this.getPreviewImagePath.bind(this); + this.onModalShown = this.onModalShown.bind(this); this.onModalHidden = this.onModalHidden.bind(this); + + this.onFileStoreChange = this.onFileStoreChange.bind(this); + + this.getPublicLink = this.getPublicLink.bind(this); + this.getPreviewImagePath = this.getPreviewImagePath.bind(this); this.onMouseEnterImage = this.onMouseEnterImage.bind(this); this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this); - var loaded = []; - var progress = []; + const loaded = []; + const progress = []; for (var i = 0; i < this.props.filenames.length; i++) { loaded.push(false); progress.push(0); } + this.state = { imgId: this.props.startId, + fileInfo: new Map(), imgHeight: '100%', - loaded: loaded, - progress: progress, - images: {}, - fileSizes: {}, - fileMimes: {}, - showFooter: false, - isPlaying: {}, - isLoading: {} + loaded, + progress, + showFooter: false }; } + handleNext(e) { if (e) { e.stopPropagation(); } - var id = this.state.imgId + 1; + let id = this.state.imgId + 1; if (id > this.props.filenames.length - 1) { id = 0; } - this.setState({imgId: id}); - this.loadImage(id); + this.showImage(id); } + handlePrev(e) { if (e) { e.stopPropagation(); } - var id = this.state.imgId - 1; + let id = this.state.imgId - 1; if (id < 0) { id = this.props.filenames.length - 1; } - this.setState({imgId: id}); - this.loadImage(id); + this.showImage(id); } + handleKeyPress(e) { - if (!e || !this.props.show) { - return; - } else if (e.keyCode === KeyCodes.RIGHT) { + if (e.keyCode === KeyCodes.RIGHT) { this.handleNext(); } else if (e.keyCode === KeyCodes.LEFT) { this.handlePrev(); } } + onModalShown(nextProps) { - this.setState({imgId: nextProps.startId}); - this.loadImage(nextProps.startId); + $(window).on('keyup', this.handleKeyPress); + + this.showImage(nextProps.startId); + + FileStore.addChangeListener(this.onFileStoreChange); } + onModalHidden() { + $(window).off('keyup', this.handleKeyPress); + if (this.refs.video) { var video = ReactDOM.findDOMNode(this.refs.video); video.pause(); video.currentTime = 0; } + + FileStore.removeChangeListener(this.onFileStoreChange); } + componentWillReceiveProps(nextProps) { if (nextProps.show === true && this.props.show === false) { this.onModalShown(nextProps); @@ -93,31 +105,65 @@ export default class ViewImageModal extends React.Component { this.onModalHidden(); } } - loadImage(id) { - var imgHeight = $(window).height() - 100; + + onFileStoreChange(filename) { + const id = this.props.filenames.indexOf(filename); + + if (id !== -1 && !this.state.loaded[id]) { + const fileInfo = this.state.fileInfo; + fileInfo.set(filename, FileStore.getInfo(filename)); + this.setState({fileInfo}); + + this.loadImage(id, filename); + } + } + + showImage(id) { + this.setState({imgId: id}); + + const imgHeight = $(window).height() - 100; this.setState({imgHeight}); - var filename = this.props.filenames[id]; + const filename = this.props.filenames[id]; - var fileInfo = Utils.splitFileLocation(filename); - var fileType = Utils.getFileType(fileInfo.ext); + if (!FileStore.hasInfo(filename)) { + // the image will actually be loaded once we know what we need to load + AsyncClient.getFileInfo(filename); + return; + } + + if (!this.state.loaded[id]) { + this.loadImage(id, filename); + } + } + + loadImage(id, filename) { + const fileInfo = FileStore.getInfo(filename); + const fileType = Utils.getFileType(fileInfo.extension); if (fileType === 'image') { - var img = new Image(); - img.load(this.getPreviewImagePath(filename), - () => { - const progress = this.state.progress; - progress[id] = img.completedPercentage; - this.setState({progress}); - }); + let previewUrl; + if (fileInfo.has_image_preview) { + previewUrl = fileInfo.getPreviewImagePath(filename); + } else { + // some images (eg animated gifs) just show the file itself and not a preview + previewUrl = Utils.getFileUrl(filename); + } + + const img = new Image(); + img.load( + previewUrl, + () => { + const progress = this.state.progress; + progress[id] = img.completedPercentage; + this.setState({progress}); + } + ); img.onload = () => { const loaded = this.state.loaded; loaded[id] = true; this.setState({loaded}); }; - var images = this.state.images; - images[id] = img; - this.setState({images}); } else { // there's nothing to load for non-image files var loaded = this.state.loaded; @@ -125,169 +171,82 @@ export default class ViewImageModal extends React.Component { this.setState({loaded}); } } - playGif(e, filename, fileUrl) { - var isLoading = this.state.isLoading; - var isPlaying = this.state.isPlaying; - - isLoading[filename] = fileUrl; - this.setState({isLoading}); - - var img = new Image(); - img.load(fileUrl); - img.onload = () => { - delete isLoading[filename]; - isPlaying[filename] = fileUrl; - this.setState({isPlaying, isLoading}); - }; - img.onError = () => { - delete isLoading[filename]; - this.setState({isLoading}); - }; - - e.stopPropagation(); - e.preventDefault(); - } - stopGif(e, filename) { - var isPlaying = this.state.isPlaying; - delete isPlaying[filename]; - this.setState({isPlaying}); - - e.stopPropagation(); - e.preventDefault(); - } - componentDidMount() { - $(window).on('keyup', this.handleKeyPress); - // keep track of whether or not this component is mounted so we can safely set the state asynchronously - this.canSetState = true; - } - componentWillUnmount() { - this.canSetState = false; - $(window).off('keyup', this.handleKeyPress); - } getPublicLink() { var data = {}; data.channel_id = this.props.channelId; data.user_id = this.props.userId; data.filename = this.props.filenames[this.state.imgId]; - Client.getPublicLink(data, - function sucess(serverData) { + Client.getPublicLink( + data, + (serverData) => { if (Utils.isMobile()) { window.location.href = serverData.public_link; } else { window.open(serverData.public_link); } }, - function error() {} + () => {} ); } + getPreviewImagePath(filename) { // Returns the path to a preview image that can be used to represent a file. var fileInfo = Utils.splitFileLocation(filename); var fileType = Utils.getFileType(fileInfo.ext); if (fileType === 'image') { - if (filename in this.state.isPlaying) { - return this.state.isPlaying[filename]; - } - // This is a temporary patch to fix issue with old files using absolute paths if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) { fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1]; } fileInfo.path = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path; - return fileInfo.path + '_preview.jpg' + '?' + Utils.getSessionIndex(); + return fileInfo.path + '_preview.jpg?' + Utils.getSessionIndex(); } // only images have proper previews, so just use a placeholder icon for non-images return Utils.getPreviewImagePathForFileType(fileType); } + onMouseEnterImage() { this.setState({showFooter: true}); } + onMouseLeaveImage() { this.setState({showFooter: false}); } + render() { if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) { return
; } - var filename = this.props.filenames[this.state.imgId]; - var fileUrl = Utils.getFileUrl(filename); - - var name = decodeURIComponent(Utils.getFileName(filename)); + const filename = this.props.filenames[this.state.imgId]; + const fileUrl = Utils.getFileUrl(filename); var content; - var bgClass = ''; if (this.state.loaded[this.state.imgId]) { - var fileInfo = Utils.splitFileLocation(filename); - var fileType = Utils.getFileType(fileInfo.ext); + // if a file has been loaded, we also have its info + const fileInfo = this.state.fileInfo.get(filename); - if (fileType === 'image') { - if (!(filename in this.state.fileMimes)) { - Client.getFileInfo( - filename, - (data) => { - if (this.canSetState) { - var fileMimes = this.state.fileMimes; - fileMimes[filename] = data.mime; - this.setState(fileMimes); - } - }, - () => {} - ); - } + const extension = Utils.splitFileLocation(filename).ext; + const fileType = Utils.getFileType(extension); - var playbackControls = ''; - if (this.state.fileMimes[filename] === 'image/gif' && !(filename in this.state.isLoading)) { - if (filename in this.state.isPlaying) { - playbackControls = ( -
this.stopGif(e, filename)} - > - {"■"} -
- ); - } else { - playbackControls = ( -
this.playGif(e, filename, fileUrl)} - > - {"►"} -
- ); - } - } - - var loadingIndicator = ''; - if (this.state.isLoading[filename] === fileUrl) { - loadingIndicator = ( - - ); - playbackControls = ''; + if (fileType === 'image') { + let previewUrl; + if (fileInfo.has_preview_image) { + previewUrl = this.getPreviewImagePath(filename); + } else { + previewUrl = fileUrl; } - // image files just show a preview of the file content = ( - - {loadingIndicator} - {playbackControls} - - + ); } else if (fileType === 'video' || fileType === 'audio') { let width = Constants.WEB_VIDEO_WIDTH; @@ -311,11 +270,13 @@ export default class ViewImageModal extends React.Component { ); } else { // non-image files include a section providing details about the file - var infoString = 'File type ' + fileInfo.ext.toUpperCase(); - if (this.state.fileSizes[filename] && this.state.fileSizes[filename] >= 0) { - infoString += ', Size ' + Utils.fileSizeToString(this.state.fileSizes[filename]); + let infoString = 'File type ' + fileInfo.extension.toUpperCase(); + if (fileInfo.size > 0) { + infoString += ', Size ' + Utils.fileSizeToString(fileInfo.size); } + const name = decodeURIComponent(Utils.getFileName(filename)); + content = (
); - bgClass = 'white-bg'; - - // asynchronously request the actual size of this file - if (!(filename in this.state.fileSizes)) { - Client.getFileInfo( - filename, - function success(data) { - if (this.canSetState) { - var fileSizes = this.state.fileSizes; - fileSizes[filename] = parseInt(data.size, 10); - this.setState(fileSizes); - } - }.bind(this), - function fail() {} - ); - } } } else { // display a progress indicator when the preview for an image is still loading - var percentage = Math.floor(this.state.progress[this.state.imgId]); - if (percentage) { - content = ( -
- - - {'Previewing ' + percentage + '%'} - -
- ); - } else { - content = ( -
- -
- ); - } - bgClass = 'black-bg'; + const progress = Math.floor(this.state.progress[this.state.imgId]); + + content = ; } - var leftArrow = ''; - var rightArrow = ''; + let leftArrow = null; + let rightArrow = null; if (this.props.filenames.length > 1) { leftArrow = (
e.stopPropagation()} @@ -471,3 +394,38 @@ ViewImageModal.propTypes = { userId: React.PropTypes.string, startId: React.PropTypes.number }; + +function LoadingImagePreview({progress}) { + let progressView = null; + if (progress) { + progressView = ( + + {'Previewing ' + progress + '%'} + + ); + } + + return ( +
+ + {progressView} +
+ ); +} + +function ImagePreview({maxHeight, fileUrl, previewUrl}) { + return ( +
+ + + ); +} diff --git a/web/react/stores/file_store.jsx b/web/react/stores/file_store.jsx new file mode 100644 index 000000000..ca8c6a96b --- /dev/null +++ b/web/react/stores/file_store.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; +import EventEmitter from 'events'; + +const ActionTypes = Constants.ActionTypes; + +const CHANGE_EVENT = 'changed'; + +class FileStore extends EventEmitter { + constructor() { + super(); + + this.addChangeListener = this.addChangeListener.bind(this); + this.removeChangeListener = this.removeChangeListener.bind(this); + this.emitChange = this.emitChange.bind(this); + + this.handleEventPayload = this.handleEventPayload.bind(this); + this.dispatchToken = AppDispatcher.register(this.handleEventPayload); + + this.fileInfo = new Map(); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + emitChange(filename) { + this.emit(CHANGE_EVENT, filename); + } + + hasInfo(filename) { + return this.fileInfo.has(filename); + } + + getInfo(filename) { + return this.fileInfo.get(filename); + } + + setInfo(filename, info) { + this.fileInfo.set(filename, info); + } + + handleEventPayload(payload) { + const action = payload.action; + + switch (action.type) { + case ActionTypes.RECIEVED_FILE_INFO: + this.setInfo(action.filename, action.info); + this.emitChange(action.filename); + break; + } + } +} + +export default new FileStore(); diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 88b5aa739..f218270da 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -769,3 +769,31 @@ export function getSuggestedCommands(command, suggestionId, component) { } ); } + +export function getFileInfo(filename) { + const callName = 'getFileInfo' + filename; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getFileInfo( + filename, + (data) => { + callTracker[callName] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_FILE_INFO, + filename, + info: data + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getFileInfo'); + } + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index a12e85f67..1c417153b 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1076,7 +1076,9 @@ export function getFileInfo(filename, success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success, + success: (data) => { + success(data); + }, error: function onError(xhr, status, err) { var e = handleError('getFileInfo', xhr, status, err); error(e); diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 29c5ecc5d..ea4921417 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -37,6 +37,7 @@ export default { RECIEVED_STATUSES: null, RECIEVED_PREFERENCE: null, RECIEVED_PREFERENCES: null, + RECIEVED_FILE_INFO: null, RECIEVED_MSG: null, diff --git a/web/sass-files/sass/partials/_files.scss b/web/sass-files/sass/partials/_files.scss index 2c341f61e..62e067437 100644 --- a/web/sass-files/sass/partials/_files.scss +++ b/web/sass-files/sass/partials/_files.scss @@ -257,3 +257,8 @@ @include opacity(0); } } + +.view-image__loading { + background: black; + min-height: 100px; +} -- cgit v1.2.3-1-g7c22