diff options
Diffstat (limited to 'web/react')
-rw-r--r-- | web/react/components/admin_console/email_settings.jsx | 2 | ||||
-rw-r--r-- | web/react/components/admin_console/rate_settings.jsx | 2 | ||||
-rw-r--r-- | web/react/components/admin_console/team_settings.jsx | 2 | ||||
-rw-r--r-- | web/react/components/posts_view.jsx | 160 | ||||
-rw-r--r-- | web/react/components/sidebar.jsx | 10 | ||||
-rw-r--r-- | web/react/components/team_general_tab.jsx | 2 | ||||
-rw-r--r-- | web/react/components/view_image.jsx | 356 | ||||
-rw-r--r-- | web/react/stores/file_store.jsx | 60 | ||||
-rw-r--r-- | web/react/utils/async_client.jsx | 28 | ||||
-rw-r--r-- | web/react/utils/client.jsx | 7 | ||||
-rw-r--r-- | web/react/utils/constants.jsx | 1 | ||||
-rw-r--r-- | web/react/utils/delayed_action.jsx | 27 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 4 |
13 files changed, 435 insertions, 226 deletions
diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index 42e3507d6..91d73dccd 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -52,7 +52,7 @@ export default class EmailSettings extends React.Component { var config = this.props.config; config.EmailSettings.EnableSignUpWithEmail = ReactDOM.findDOMNode(this.refs.allowSignUpWithEmail).checked; config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked; - config.EmailSettings.SendPushlNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked; + config.EmailSettings.SendPushNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked; config.EmailSettings.RequireEmailVerification = ReactDOM.findDOMNode(this.refs.requireEmailVerification).checked; config.EmailSettings.FeedbackName = ReactDOM.findDOMNode(this.refs.feedbackName).value.trim(); config.EmailSettings.FeedbackEmail = ReactDOM.findDOMNode(this.refs.feedbackEmail).value.trim(); diff --git a/web/react/components/admin_console/rate_settings.jsx b/web/react/components/admin_console/rate_settings.jsx index ca9fcb074..aabb24326 100644 --- a/web/react/components/admin_console/rate_settings.jsx +++ b/web/react/components/admin_console/rate_settings.jsx @@ -241,7 +241,7 @@ export default class RateSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.EnableRateLimiter || this.state.VaryByRemoteAddr} /> - <p className='help-text'>{'When filled in, vary rate limiting by HTTP header field specified (e.g. when configuring Ngnix set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}</p> + <p className='help-text'>{'When filled in, vary rate limiting by HTTP header field specified (e.g. when configuring NGINX set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}</p> </div> </div> diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx index 7991b9a01..9d958ce91 100644 --- a/web/react/components/admin_console/team_settings.jsx +++ b/web/react/components/admin_console/team_settings.jsx @@ -206,7 +206,7 @@ export default class TeamSettings extends React.Component { defaultValue={this.props.config.TeamSettings.RestrictCreationToDomains} onChange={this.handleChange} /> - <p className='help-text'>{'Teams can only be created from a specific domain (e.g. "mattermost.org") or list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").'}</p> + <p className='help-text'>{'Teams and user accounts can only be created from a specific domain (e.g. "mattermost.org") or list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").'}</p> </div> </div> diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx index e116fdeea..a28efbd04 100644 --- a/web/react/components/posts_view.jsx +++ b/web/react/components/posts_view.jsx @@ -7,6 +7,7 @@ import * as EventHelpers from '../dispatcher/event_helpers.jsx'; import * as Utils from '../utils/utils.jsx'; import Post from './post.jsx'; import Constants from '../utils/constants.jsx'; +import DelayedAction from '../utils/delayed_action.jsx'; const Preferences = Constants.Preferences; export default class PostsView extends React.Component { @@ -15,18 +16,26 @@ export default class PostsView extends React.Component { this.updateState = this.updateState.bind(this); this.handleScroll = this.handleScroll.bind(this); + this.handleScrollStop = this.handleScrollStop.bind(this); this.isAtBottom = this.isAtBottom.bind(this); this.loadMorePostsTop = this.loadMorePostsTop.bind(this); this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); this.createPosts = this.createPosts.bind(this); this.updateScrolling = this.updateScrolling.bind(this); this.handleResize = this.handleResize.bind(this); + this.scrollToBottom = this.scrollToBottom.bind(this); this.jumpToPostNode = null; this.wasAtBottom = true; this.scrollHeight = 0; - this.state = {displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false')}; + this.scrollStopAction = new DelayedAction(this.handleScrollStop); + + this.state = { + displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), + isScrolling: false, + topPostId: null + }; } static get SCROLL_TYPE_FREE() { return 1; @@ -69,6 +78,55 @@ export default class PostsView extends React.Component { this.props.postViewScrolled(this.isAtBottom()); this.prevScrollHeight = this.refs.postlist.scrollHeight; this.prevOffsetTop = this.jumpToPostNode.offsetTop; + + this.updateFloatingTimestamp(); + + if (!this.state.isScrolling) { + this.setState({ + isScrolling: true + }); + } + + this.scrollStopAction.fireAfter(1000); + } + handleScrollStop() { + this.setState({ + isScrolling: false + }); + } + updateFloatingTimestamp() { + // skip this in non-mobile view since that's when the timestamp is visible + if ($(window).width() > 768) { + return; + } + + if (this.props.postList) { + // iterate through posts starting at the bottom since users are more likely to be viewing newer posts + for (let i = 0; i < this.props.postList.order.length; i++) { + const id = this.props.postList.order[i]; + const element = ReactDOM.findDOMNode(this.refs[id]); + + if (!element || element.offsetTop + element.clientHeight <= this.refs.postlist.scrollTop) { + // this post is off the top of the screen so the last one is at the top of the screen + let topPostId; + + if (i > 0) { + topPostId = this.props.postList.order[i - 1]; + } else { + // the first post we look at should always be on the screen, but handle that case anyway + topPostId = id; + } + + if (topPostId !== this.state.topPostId) { + this.setState({ + topPostId + }); + } + + break; + } + } + } } loadMorePostsTop() { this.props.loadMorePostsTopClicked(); @@ -226,9 +284,7 @@ export default class PostsView extends React.Component { } updateScrolling() { if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) { - window.requestAnimationFrame(() => { - this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; - }); + this.scrollToBottom(); } else if (this.props.scrollType === PostsView.SCROLL_TYPE_NEW_MESSAGE) { window.requestAnimationFrame(() => { // If separator exists scroll to it. Otherwise scroll to bottom. @@ -278,6 +334,11 @@ export default class PostsView extends React.Component { handleResize() { this.updateScrolling(); } + scrollToBottom() { + window.requestAnimationFrame(() => { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + }); + } componentDidMount() { if (this.props.postList != null) { this.updateScrolling(); @@ -322,6 +383,12 @@ export default class PostsView extends React.Component { if (nextState.displayNameType !== this.state.displayNameType) { return true; } + if (this.state.topPostId !== nextState.topPostId) { + return true; + } + if (this.state.isScrolling !== nextState.isScrolling) { + return true; + } return false; } @@ -377,20 +444,36 @@ export default class PostsView extends React.Component { } } + let topPost = null; + if (this.state.topPostId) { + topPost = this.props.postList.posts[this.state.topPostId]; + } + return ( - <div - ref='postlist' - className={'post-list-holder-by-time ' + activeClass} - onScroll={this.handleScroll} - > - <div className='post-list__table'> - <div - ref='postlistcontent' - className='post-list__content' - > - {moreMessagesTop} - {postElements} - {moreMessagesBottom} + <div className={activeClass}> + <FloatingTimestamp + isScrolling={this.state.isScrolling} + post={topPost} + /> + <ScrollToBottomArrows + isScrolling={this.state.isScrolling} + atBottom={this.wasAtBottom} + onClick={this.scrollToBottom} + /> + <div + ref='postlist' + className='post-list-holder-by-time' + onScroll={this.handleScroll} + > + <div className='post-list__table'> + <div + ref='postlistcontent' + className='post-list__content' + > + {moreMessagesTop} + {postElements} + {moreMessagesBottom} + </div> </div> </div> </div> @@ -414,3 +497,46 @@ PostsView.propTypes = { messageSeparatorTime: React.PropTypes.number, postsToHighlight: React.PropTypes.object }; + +function FloatingTimestamp({isScrolling, post}) { + // only show on mobile + if ($(window).width() > 768) { + return <noscript />; + } + + if (!post) { + return <noscript />; + } + + const dateString = Utils.getDateForUnixTicks(post.create_at).toDateString(); + + let className = 'post-list__timestamp'; + if (isScrolling) { + className += ' scrolling'; + } + + return ( + <div className={className}> + <span>{dateString}</span> + </div> + ); +} + +function ScrollToBottomArrows({isScrolling, atBottom, onClick}) { + // only show on mobile + if ($(window).width() > 768) { + return <noscript />; + } + + let className = 'post-list__arrows'; + if (isScrolling && !atBottom) { + className += ' scrolling'; + } + + return ( + <div + className={className} + onClick={onClick} + /> + ); +} diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index cc2279b57..18c360cb8 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -39,6 +39,7 @@ export default class Sidebar extends React.Component { this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this); this.handleResize = this.handleResize.bind(this); + this.showMoreChannelsModal = this.showMoreChannelsModal.bind(this); this.showNewChannelModal = this.showNewChannelModal.bind(this); this.hideNewChannelModal = this.hideNewChannelModal.bind(this); this.showMoreDirectChannelsModal = this.showMoreDirectChannelsModal.bind(this); @@ -250,6 +251,11 @@ export default class Sidebar extends React.Component { return a.display_name.localeCompare(b.display_name); } + showMoreChannelsModal() { + // manually show the modal because using data-toggle messes with keyboard focus when the modal is dismissed + $('#more_channels').modal({'data-channeltype': 'O'}).modal('show'); + } + showNewChannelModal(type) { this.setState({newChannelModalType: type}); } @@ -594,10 +600,8 @@ export default class Sidebar extends React.Component { <li> <a href='#' - data-toggle='modal' className='nav-more' - data-target='#more_channels' - data-channeltype='O' + onClick={this.showMoreChannelsModal} > {'More...'} </a> diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx index dc615f2e8..cc06a940e 100644 --- a/web/react/components/team_general_tab.jsx +++ b/web/react/components/team_general_tab.jsx @@ -424,7 +424,7 @@ export default class GeneralTab extends React.Component { </div> </div> </div> - <div className='setting-list__hint'>{'Your Invite Code is used in the URL sent to people to join your team. Regenerating your Invite Code will invalidate the URLs in previous invitations, unless "Allow anyone to sign-up from login page" is enabled.'}</div> + <div className='setting-list__hint'>{'The Invite Code is used as part of the URL in the team invitation link created by **Get Team Invite Link** in the main menu. Regenerating creates a new team invitation link and invalidates the previous link.'}</div> </div> ); diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 820f8fd8e..7edf6283b 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 <div/>; } - 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 = ( - <div - className='file-playback-controls stop' - onClick={(e) => this.stopGif(e, filename)} - > - {"■"} - </div> - ); - } else { - playbackControls = ( - <div - className='file-playback-controls play' - onClick={(e) => this.playGif(e, filename, fileUrl)} - > - {"►"} - </div> - ); - } - } - - var loadingIndicator = ''; - if (this.state.isLoading[filename] === fileUrl) { - loadingIndicator = ( - <img - className='spinner file__loading' - src='/static/images/load.gif' - /> - ); - 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 = ( - <a - href={fileUrl} - target='_blank' - > - {loadingIndicator} - {playbackControls} - <img - style={{maxHeight: this.state.imgHeight}} - ref='image' - src={this.getPreviewImagePath(filename)} - /> - </a> + <ImagePreview + fileUrl={fileUrl} + previewUrl={previewUrl} + maxHeight={this.state.imgHeight} + /> ); } 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 = ( <div className='file-details__container'> <a @@ -335,53 +296,16 @@ export default class ViewImageModal extends React.Component { </div> </div> ); - 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 = ( - <div> - <img - className='loader-image' - src='/static/images/load.gif' - /> - <span className='loader-percent'> - {'Previewing ' + percentage + '%'} - </span> - </div> - ); - } else { - content = ( - <div> - <img - className='loader-image' - src='/static/images/load.gif' - /> - </div> - ); - } - bgClass = 'black-bg'; + const progress = Math.floor(this.state.progress[this.state.imgId]); + + content = <LoadingImagePreview progress={progress} />; } - var leftArrow = ''; - var rightArrow = ''; + let leftArrow = null; + let rightArrow = null; if (this.props.filenames.length > 1) { leftArrow = ( <a @@ -427,7 +351,6 @@ export default class ViewImageModal extends React.Component { onClick={this.props.onModalDismissed} > <div - className={bgClass} onMouseEnter={this.onMouseEnterImage} onMouseLeave={this.onMouseLeaveImage} onClick={(e) => 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 = ( + <span className='loader-percent'> + {'Loading ' + progress + '%'} + </span> + ); + } + + return ( + <div className='view-image__loading'> + <img + className='loader-image' + src='/static/images/load.gif' + /> + {progressView} + </div> + ); +} + +function ImagePreview({maxHeight, fileUrl, previewUrl}) { + return ( + <a + href={fileUrl} + target='_blank' + > + <img + style={{maxHeight}} + src={previewUrl} + /> + </a> + ); +} 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..8a4cee589 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -246,6 +246,7 @@ export function loginByEmail(name, email, password, success, error) { data: JSON.stringify({name, email, password}), success: function onSuccess(data, textStatus, xhr) { track('api', 'api_users_login_success', data.team_id, 'email', data.email); + sessionStorage.removeItem(data.id + '_last_error'); BrowserStore.signalLogin(); success(data, textStatus, xhr); }, @@ -267,6 +268,8 @@ export function loginByLdap(teamName, id, password, success, error) { data: JSON.stringify({teamName, id, password}), success: function onSuccess(data, textStatus, xhr) { track('api', 'api_users_loginLdap_success', data.team_id, 'id', id); + sessionStorage.removeItem(data.id + '_last_error'); + BrowserStore.signalLogin(); success(data, textStatus, xhr); }, error: function onError(xhr, status, err) { @@ -1076,7 +1079,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/react/utils/delayed_action.jsx b/web/react/utils/delayed_action.jsx new file mode 100644 index 000000000..4f6239ad0 --- /dev/null +++ b/web/react/utils/delayed_action.jsx @@ -0,0 +1,27 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default class DelayedAction { + constructor(action) { + this.action = action; + + this.timer = -1; + + // bind fire since it doesn't get passed the correct this value with setTimeout + this.fire = this.fire.bind(this); + } + + fire() { + this.action(); + + this.timer = -1; + } + + fireAfter(timeout) { + if (this.timer >= 0) { + window.clearTimeout(this.timer); + } + + this.timer = window.setTimeout(this.fire, timeout); + } +} diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 52d88c5b9..24d27b10a 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -567,7 +567,7 @@ export function applyTheme(theme) { } if (theme.sidebarHeaderBg) { - changeCss('.sidebar--left .team__header, .sidebar--menu .team__header', 'background:' + theme.sidebarHeaderBg, 1); + changeCss('.sidebar--left .team__header, .sidebar--menu .team__header, .post-list__timestamp', 'background:' + theme.sidebarHeaderBg, 1); changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1); changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1); changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1); @@ -575,7 +575,7 @@ export function applyTheme(theme) { } if (theme.sidebarHeaderTextColor) { - changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info', 'color:' + theme.sidebarHeaderTextColor, 1); + changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info, .post-list__timestamp', 'color:' + theme.sidebarHeaderTextColor, 1); changeCss('.sidebar--left .team__header .navbar-right .dropdown__icon, .sidebar--menu .team__header .navbar-right .dropdown__icon', 'fill:' + theme.sidebarHeaderTextColor, 1); changeCss('.sidebar--left .team__header .user__name, .sidebar--menu .team__header .user__name', 'color:' + changeOpacity(theme.sidebarHeaderTextColor, 0.8), 1); changeCss('.sidebar--left .team__header:hover .user__name, .sidebar--menu .team__header:hover .user__name', 'color:' + theme.sidebarHeaderTextColor, 1); |