summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/react/components/admin_console/email_settings.jsx2
-rw-r--r--web/react/components/admin_console/rate_settings.jsx2
-rw-r--r--web/react/components/admin_console/team_settings.jsx2
-rw-r--r--web/react/components/posts_view.jsx160
-rw-r--r--web/react/components/sidebar.jsx10
-rw-r--r--web/react/components/signup_team.jsx4
-rw-r--r--web/react/components/team_general_tab.jsx2
-rw-r--r--web/react/components/view_image.jsx356
-rw-r--r--web/react/stores/file_store.jsx60
-rw-r--r--web/react/utils/async_client.jsx28
-rw-r--r--web/react/utils/client.jsx4
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/delayed_action.jsx27
-rw-r--r--web/react/utils/utils.jsx4
-rw-r--r--web/sass-files/sass/partials/_files.scss5
-rw-r--r--web/sass-files/sass/partials/_post.scss58
-rw-r--r--web/sass-files/sass/partials/_responsive.scss5
-rw-r--r--web/static/images/postArrows.pngbin0 -> 5684 bytes
18 files changed, 499 insertions, 231 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/signup_team.jsx b/web/react/components/signup_team.jsx
index 0e05bc533..a554427d5 100644
--- a/web/react/components/signup_team.jsx
+++ b/web/react/components/signup_team.jsx
@@ -28,6 +28,8 @@ export default class TeamSignUp extends React.Component {
this.state = {page: 'email'};
} else if (global.window.mm_config.EnableSignUpWithGitLab === 'true') {
this.state = {page: 'gitlab'};
+ } else {
+ this.state = {page: 'none'};
}
}
@@ -119,6 +121,8 @@ export default class TeamSignUp extends React.Component {
<SSOSignupPage service={Constants.GOOGLE_SERVICE} />
</div>
);
+ } else if (this.state.page === 'none') {
+ return (<div>{'No team creation method has been enabled. Please contact an administrator for access.'}</div>);
}
}
}
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..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/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);
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;
+}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index 67b28381f..937b08084 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -211,6 +211,10 @@ body.ios {
overflow-y: hidden;
height: 100%;
+ .inactive {
+ display: none;
+ }
+
.post-list-holder-by-time {
background: #fff;
overflow-y: scroll;
@@ -222,9 +226,6 @@ body.ios {
&::-webkit-scrollbar {
width: 0px !important;
}
- &.inactive {
- display: none;
- }
&.active {
display: inline;
}
@@ -247,6 +248,50 @@ body.ios {
}
}
+.post-list__timestamp {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ z-index: 50;
+ width: 120px;
+ text-align: center;
+ background: $primary-color;
+ color: #fff;
+ @include border-radius(3px);
+ font-size: 12px;
+ line-height: 25px;
+ margin-left: -60px;
+ -webkit-font-smoothing: initial;
+ @include single-transition(all, 0.3s, ease);
+ @include translateY(-45px);
+ @include opacity(0);
+ display: none;
+
+ &.scrolling {
+ @include single-transition(all, 0.3s, ease);
+ @include translateY(0);
+ @include opacity(0.8);
+ }
+}
+
+.post-list__arrows {
+ background: url('../images/postArrows.png') center;
+ @include background-size(28px 28px);
+ background-repeat: no-repeat;
+ width: 40px;
+ height: 40px;
+ position: absolute;
+ bottom: 50px;
+ right: 5px;
+ z-index: 50;
+ @include opacity(0);
+ @include single-transition(all, 0.3s);
+
+ &.scrolling {
+ @include opacity(1);
+ }
+}
+
.post-create__container {
form {
width: 100%;
@@ -379,7 +424,7 @@ body.ios {
p {
- margin: 0 0 1em;
+ margin: 0;
line-height: 1.6em;
font-size: 0.97em;
white-space: pre-wrap;
@@ -631,9 +676,14 @@ body.ios {
}
ul {
+ margin-bottom: 0.6em;
padding: 5px 0 0 20px;
}
+ ul + p {
+ margin-top: 1em;
+ }
+
ul, ol {
p {
margin-bottom: 0;
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index e1ceea3ad..635b46077 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -242,6 +242,9 @@
}
}
}
+ .post-list__timestamp {
+ display: block;
+ }
.signup-team__container {
padding: 30px 0;
margin-bottom: 30px;
@@ -800,4 +803,4 @@
font-size: 2em;
}
}
-} \ No newline at end of file
+}
diff --git a/web/static/images/postArrows.png b/web/static/images/postArrows.png
new file mode 100644
index 000000000..7b5919fc3
--- /dev/null
+++ b/web/static/images/postArrows.png
Binary files differ