diff options
Diffstat (limited to 'web/react')
85 files changed, 2316 insertions, 877 deletions
diff --git a/web/react/components/about_build_modal.jsx b/web/react/components/about_build_modal.jsx index e8a46086a..6962876d4 100644 --- a/web/react/components/about_build_modal.jsx +++ b/web/react/components/about_build_modal.jsx @@ -14,7 +14,7 @@ export default class AboutBuildModal extends React.Component { } render() { - const config = global.window.config; + const config = global.window.mm_config; return ( <Modal diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx index 74d6c64e3..2c944913f 100644 --- a/web/react/components/activity_log_modal.jsx +++ b/web/react/components/activity_log_modal.jsx @@ -81,6 +81,7 @@ export default class ActivityLogModal extends React.Component { const currentSession = this.state.sessions[i]; const lastAccessTime = new Date(currentSession.last_activity_at); const firstAccessTime = new Date(currentSession.create_at); + let devicePlatform = currentSession.props.platform; let devicePicture = ''; if (currentSession.props.platform === 'Windows') { @@ -88,7 +89,12 @@ export default class ActivityLogModal extends React.Component { } else if (currentSession.props.platform === 'Macintosh' || currentSession.props.platform === 'iPhone') { devicePicture = 'fa fa-apple'; } else if (currentSession.props.platform === 'Linux') { - devicePicture = 'fa fa-linux'; + if (currentSession.props.os.indexOf('Android') >= 0) { + devicePlatform = 'Android'; + devicePicture = 'fa fa-android'; + } else { + devicePicture = 'fa fa-linux'; + } } let moreInfo; @@ -119,7 +125,7 @@ export default class ActivityLogModal extends React.Component { className='activity-log__table' > <div className='activity-log__report'> - <div className='report__platform'><i className={devicePicture} />{currentSession.props.platform}</div> + <div className='report__platform'><i className={devicePicture} />{devicePlatform}</div> <div className='report__info'> <div>{`Last activity: ${lastAccessTime.toDateString()}, ${lastAccessTime.toLocaleTimeString()}`}</div> {moreInfo} diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index f2fb8ac78..f770d166c 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -40,9 +40,13 @@ export default class AdminController extends React.Component { config: AdminStore.getConfig(), teams: AdminStore.getAllTeams(), selectedTeams, - selected: 'service_settings', - selectedTeam: null + selected: props.tab || 'service_settings', + selectedTeam: props.teamId || null }; + + if (!props.tab) { + history.replaceState(null, null, `/admin_console/${this.state.selected}`); + } } componentDidMount() { @@ -142,7 +146,9 @@ export default class AdminController extends React.Component { } else if (this.state.selected === 'service_settings') { tab = <ServiceSettingsTab config={this.state.config} />; } else if (this.state.selected === 'team_users') { - tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; + if (this.state.teams) { + tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; + } } } diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 4c2a473b6..b0e01ff17 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -24,6 +24,7 @@ export default class AdminSidebar extends React.Component { handleClick(name, teamId, e) { e.preventDefault(); this.props.selectTab(name, teamId); + history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`); } isSelected(name, teamId) { @@ -53,6 +54,9 @@ export default class AdminSidebar extends React.Component { } componentDidMount() { + if ($(window).width() > 768) { + $('.nav-pills__container').perfectScrollbar(); + } } showTeamSelect(e) { diff --git a/web/react/components/admin_console/admin_sidebar_header.jsx b/web/react/components/admin_console/admin_sidebar_header.jsx index c80811bcd..fd6d92c4a 100644 --- a/web/react/components/admin_console/admin_sidebar_header.jsx +++ b/web/react/components/admin_console/admin_sidebar_header.jsx @@ -3,6 +3,7 @@ var AdminNavbarDropdown = require('./admin_navbar_dropdown.jsx'); var UserStore = require('../../stores/user_store.jsx'); +var Utils = require('../../utils/utils.jsx'); export default class SidebarHeader extends React.Component { constructor(props) { @@ -36,7 +37,7 @@ export default class SidebarHeader extends React.Component { profilePicture = ( <img className='user__picture' - src={'/api/v1/users/' + me.id + '/image?time=' + me.update_at} + src={'/api/v1/users/' + me.id + '/image?time=' + me.update_at + '&' + Utils.getSessionIndex()} /> ); } diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index 01759b222..40e00ff04 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -440,9 +440,11 @@ export default class EmailSettings extends React.Component { className='table table-bordered' cellPadding='5' > - <tr><td className='help-text'>{'None'}</td><td className='help-text'>{'Mattermost will send email over an unsecure connection.'}</td></tr> - <tr><td className='help-text'>{'TLS'}</td><td className='help-text'>{'Encrypts the communication between Mattermost and your email server.'}</td></tr> - <tr><td className='help-text'>{'STARTTLS'}</td><td className='help-text'>{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'}</td></tr> + <tbody> + <tr><td className='help-text'>{'None'}</td><td className='help-text'>{'Mattermost will send email over an unsecure connection.'}</td></tr> + <tr><td className='help-text'>{'TLS'}</td><td className='help-text'>{'Encrypts the communication between Mattermost and your email server.'}</td></tr> + <tr><td className='help-text'>{'STARTTLS'}</td><td className='help-text'>{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'}</td></tr> + </tbody> </table> </div> <div className='help-text'> diff --git a/web/react/components/admin_console/log_settings.jsx b/web/react/components/admin_console/log_settings.jsx index 931818bb8..7e9eda89b 100644 --- a/web/react/components/admin_console/log_settings.jsx +++ b/web/react/components/admin_console/log_settings.jsx @@ -249,22 +249,24 @@ export default class LogSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.fileEnable} /> - <p className='help-text'> + <div className='help-text'> {'Format of log message output. If blank will be set to "[%D %T] [%L] %M", where:'} <div className='help-text'> <table className='table table-bordered' cellPadding='5' > - <tr><td className='help-text'>{'%T'}</td><td className='help-text'>{'Time (15:04:05 MST)'}</td></tr> - <tr><td className='help-text'>{'%D'}</td><td className='help-text'>{'Date (2006/01/02)'}</td></tr> - <tr><td className='help-text'>{'%d'}</td><td className='help-text'>{'Date (01/02/06)'}</td></tr> - <tr><td className='help-text'>{'%L'}</td><td className='help-text'>{'Level (DEBG, INFO, EROR)'}</td></tr> - <tr><td className='help-text'>{'%S'}</td><td className='help-text'>{'Source'}</td></tr> - <tr><td className='help-text'>{'%M'}</td><td className='help-text'>{'Message'}</td></tr> + <tbody> + <tr><td className='help-text'>{'%T'}</td><td className='help-text'>{'Time (15:04:05 MST)'}</td></tr> + <tr><td className='help-text'>{'%D'}</td><td className='help-text'>{'Date (2006/01/02)'}</td></tr> + <tr><td className='help-text'>{'%d'}</td><td className='help-text'>{'Date (01/02/06)'}</td></tr> + <tr><td className='help-text'>{'%L'}</td><td className='help-text'>{'Level (DEBG, INFO, EROR)'}</td></tr> + <tr><td className='help-text'>{'%S'}</td><td className='help-text'>{'Source'}</td></tr> + <tr><td className='help-text'>{'%M'}</td><td className='help-text'>{'Message'}</td></tr> + </tbody> </table> </div> - </p> + </div> </div> </div> diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx index 4105ba6da..53c89a942 100644 --- a/web/react/components/admin_console/service_settings.jsx +++ b/web/react/components/admin_console/service_settings.jsx @@ -36,6 +36,7 @@ export default class ServiceSettings extends React.Component { config.ServiceSettings.SegmentDeveloperKey = ReactDOM.findDOMNode(this.refs.SegmentDeveloperKey).value.trim(); config.ServiceSettings.GoogleDeveloperKey = ReactDOM.findDOMNode(this.refs.GoogleDeveloperKey).value.trim(); config.ServiceSettings.EnableIncomingWebhooks = ReactDOM.findDOMNode(this.refs.EnableIncomingWebhooks).checked; + config.ServiceSettings.EnableOutgoingWebhooks = React.findDOMNode(this.refs.EnableOutgoingWebhooks).checked; config.ServiceSettings.EnablePostUsernameOverride = ReactDOM.findDOMNode(this.refs.EnablePostUsernameOverride).checked; config.ServiceSettings.EnablePostIconOverride = ReactDOM.findDOMNode(this.refs.EnablePostIconOverride).checked; config.ServiceSettings.EnableTesting = ReactDOM.findDOMNode(this.refs.EnableTesting).checked; @@ -207,7 +208,40 @@ export default class ServiceSettings extends React.Component { </div> </div> - <div className='form-group'> + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='EnableOutgoingWebhooks' + > + {'Enable Outgoing Webhooks: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='EnableOutgoingWebhooks' + value='true' + ref='EnableOutgoingWebhooks' + defaultChecked={this.props.config.ServiceSettings.EnableOutgoingWebhooks} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='EnableOutgoingWebhooks' + value='false' + defaultChecked={!this.props.config.ServiceSettings.EnableOutgoingWebhooks} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'When true, outgoing webhooks will be allowed.'}</p> + </div> + </div> + + <div className='form-group'> <label className='control-label col-sm-4' htmlFor='EnablePostUsernameOverride' diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx index da4299714..9ecd14a1e 100644 --- a/web/react/components/admin_console/team_settings.jsx +++ b/web/react/components/admin_console/team_settings.jsx @@ -31,6 +31,7 @@ export default class TeamSettings extends React.Component { config.TeamSettings.RestrictCreationToDomains = ReactDOM.findDOMNode(this.refs.RestrictCreationToDomains).value.trim(); config.TeamSettings.EnableTeamCreation = ReactDOM.findDOMNode(this.refs.EnableTeamCreation).checked; config.TeamSettings.EnableUserCreation = ReactDOM.findDOMNode(this.refs.EnableUserCreation).checked; + config.TeamSettings.RestrictTeamNames = ReactDOM.findDOMNode(this.refs.RestrictTeamNames).checked; var MaxUsersPerTeam = 50; if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10))) { @@ -209,6 +210,39 @@ export default class TeamSettings extends React.Component { </div> <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='RestrictTeamNames' + > + {'Restrict Team Names: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='RestrictTeamNames' + value='true' + ref='RestrictTeamNames' + defaultChecked={this.props.config.TeamSettings.RestrictTeamNames} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='RestrictTeamNames' + value='false' + defaultChecked={!this.props.config.TeamSettings.RestrictTeamNames} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'When true, You cannot create a team name with reserved words like www, admin, support, test, channel, etc'}</p> + </div> + </div> + + <div className='form-group'> <div className='col-sm-12'> {serverError} <button diff --git a/web/react/components/admin_console/user_item.jsx b/web/react/components/admin_console/user_item.jsx index 395e22e6c..f7e92672d 100644 --- a/web/react/components/admin_console/user_item.jsx +++ b/web/react/components/admin_console/user_item.jsx @@ -215,7 +215,7 @@ export default class UserItem extends React.Component { <div className='row member-div'> <img className='post-profile-img pull-left' - src={`/api/v1/users/${user.id}/image?time=${user.update_at}`} + src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`} height='36' width='36' /> diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 7582de6c4..1b709336f 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -4,7 +4,6 @@ const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const PostStore = require('../stores/post_store.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); const NavbarSearchBox = require('./search_bar.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); @@ -25,7 +24,6 @@ export default class ChannelHeader extends React.Component { super(props); this.onListenerChange = this.onListenerChange.bind(this); - this.onSocketChange = this.onSocketChange.bind(this); this.handleLeave = this.handleLeave.bind(this); this.searchMentions = this.searchMentions.bind(this); @@ -45,7 +43,6 @@ export default class ChannelHeader extends React.Component { ChannelStore.addExtraInfoChangeListener(this.onListenerChange); PostStore.addSearchChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); - SocketStore.addChangeListener(this.onSocketChange); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); @@ -60,16 +57,9 @@ export default class ChannelHeader extends React.Component { } $('.channel-header__info .description').popover({placement: 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}}); } - onSocketChange(msg) { - if (msg.action === 'new_user' || - msg.action === 'user_added' || - (msg.action === 'user_removed' && msg.user_id !== UserStore.getCurrentId())) { - AsyncClient.getChannelExtraInfo(true); - } - } handleLeave() { Client.leaveChannel(this.state.channel.id, - function handleLeaveSuccess() { + () => { AppDispatcher.handleViewAction({ type: ActionTypes.LEAVE_CHANNEL, id: this.state.channel.id @@ -77,8 +67,8 @@ export default class ChannelHeader extends React.Component { const townsquare = ChannelStore.getByName('town-square'); Utils.switchChannel(townsquare); - }.bind(this), - function handleLeaveError(err) { + }, + (err) => { AsyncClient.dispatchError(err, 'handleLeave'); } ); diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index 270631db2..55b4a55c0 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -26,7 +26,6 @@ export default class ChannelLoader extends React.Component { } componentDidMount() { /* Initial aysnc loads */ - AsyncClient.getMe(); AsyncClient.getPosts(ChannelStore.getCurrentId()); AsyncClient.getChannels(true, true); AsyncClient.getChannelExtraInfo(true); diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx index 6151d4bdd..43700bf36 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications.jsx @@ -136,16 +136,15 @@ export default class ChannelNotifications extends React.Component { var inputs = []; inputs.push( - <div> + <div key='channel-notification-level-radio'> <div className='radio'> <label> <input type='radio' checked={notifyActive[0]} onChange={this.handleUpdateNotifyLevel.bind(this, 'default')} - > + /> {`Global default (${globalNotifyLevelName})`} - </input> </label> <br/> </div> @@ -155,9 +154,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[1]} onChange={this.handleUpdateNotifyLevel.bind(this, 'all')} - > + /> {'For all activity'} - </input> </label> <br/> </div> @@ -167,9 +165,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[2]} onChange={this.handleUpdateNotifyLevel.bind(this, 'mention')} - > + /> {'Only for mentions'} - </input> </label> <br/> </div> @@ -179,9 +176,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={notifyActive[3]} onChange={this.handleUpdateNotifyLevel.bind(this, 'none')} - > + /> {'Never'} - </input> </label> </div> </div> @@ -274,16 +270,15 @@ export default class ChannelNotifications extends React.Component { if (this.state.activeSection === 'markUnreadLevel') { const inputs = [( - <div> + <div key='channel-notification-unread-radio'> <div className='radio'> <label> <input type='radio' checked={this.state.markUnreadLevel === 'all'} onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'all')} - > + /> {'For all unread messages'} - </input> </label> <br /> </div> @@ -293,9 +288,8 @@ export default class ChannelNotifications extends React.Component { type='radio' checked={this.state.markUnreadLevel === 'mention'} onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'mention')} - > + /> {'Only for mentions'} - </input> </label> <br /> </div> @@ -370,7 +364,7 @@ export default class ChannelNotifications extends React.Component { data-dismiss='modal' > <span aria-hidden='true'>×</span> - <span className='sr-only'>Close</span> + <span className='sr-only'>{'Close'}</span> </button> <h4 className='modal-title'>Notification Preferences for <span className='name'>{this.state.title}</span></h4> </div> diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 2df3dc40f..435c7d542 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -13,8 +13,10 @@ const MsgTyping = require('./msg_typing.jsx'); const FileUpload = require('./file_upload.jsx'); const FilePreview = require('./file_preview.jsx'); const Utils = require('../utils/utils.jsx'); + const Constants = require('../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; +const KeyCodes = Constants.KeyCodes; export default class CreateComment extends React.Component { constructor(props) { @@ -25,6 +27,7 @@ export default class CreateComment extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); + this.handleArrowUp = this.handleArrowUp.bind(this); this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); @@ -32,6 +35,7 @@ export default class CreateComment extends React.Component { this.removePreview = this.removePreview.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.getFileCount = this.getFileCount.bind(this); + this.handleResize = this.handleResize.bind(this); PostStore.clearCommentDraftUploads(); @@ -40,13 +44,23 @@ export default class CreateComment extends React.Component { messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, previews: draft.previews, - submitting: false + submitting: false, + windowWidth: Utils.windowWidth() }; } + componentDidMount() { + window.addEventListener('resize', this.handleResize); + } + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + } + handleResize() { + this.setState({windowWidth: Utils.windowWidth()}); + } componentDidUpdate(prevProps, prevState) { if (prevState.uploadsInProgress < this.state.uploadsInProgress) { $('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight); - if ($(window).width() > 768) { + if (this.state.windowWidth > 768) { $('.post-right__scroll').perfectScrollbar('update'); } } @@ -147,6 +161,26 @@ export default class CreateComment extends React.Component { $('.post-right__scroll').perfectScrollbar('update'); this.setState({messageText: messageText}); } + handleArrowUp(e) { + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { + e.preventDefault(); + + const channelId = ChannelStore.getCurrentId(); + const lastPost = PostStore.getCurrentUsersLatestPost(channelId, this.props.rootId); + if (!lastPost) { + return; + } + + AppDispatcher.handleViewAction({ + type: ActionTypes.RECIEVED_EDIT_POST, + refocusId: '#reply_textbox', + title: 'Comment', + message: lastPost.message, + postId: lastPost.id, + channelId: lastPost.channel_id + }); + } + } handleUploadStart(clientIds) { let draft = PostStore.getCommentDraft(this.props.rootId); @@ -279,6 +313,7 @@ export default class CreateComment extends React.Component { <Textbox onUserInput={this.handleUserInput} onKeyPress={this.commentMsgKeyPress} + onKeyDown={this.handleArrowUp} messageText={this.state.messageText} createMessage='Add a comment...' initialText='' diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 2581bdcca..055be112d 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -37,6 +37,8 @@ export default class CreatePost extends React.Component { this.onChange = this.onChange.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleArrowUp = this.handleArrowUp.bind(this); + this.handleResize = this.handleResize.bind(this); + this.sendMessage = this.sendMessage.bind(this); PostStore.clearDraftUploads(); @@ -48,9 +50,17 @@ export default class CreatePost extends React.Component { uploadsInProgress: draft.uploadsInProgress, previews: draft.previews, submitting: false, - initialText: draft.messageText + initialText: draft.messageText, + windowWidth: Utils.windowWidth(), + windowHeight: Utils.windowHeight() }; } + handleResize() { + this.setState({ + windowWidth: Utils.windowWidth(), + windowHeight: Utils.windowHeight() + }); + } componentDidUpdate(prevProps, prevState) { if (prevState.previews.length !== this.state.previews.length) { this.resizePostHolder(); @@ -61,6 +71,11 @@ export default class CreatePost extends React.Component { this.resizePostHolder(); return; } + + if (prevState.windowWidth !== this.state.windowWidth || prevState.windowHeight !== this.state.windowHeight) { + this.resizePostHolder(); + return; + } } getCurrentDraft() { const draft = PostStore.getCurrentDraft(); @@ -108,6 +123,11 @@ export default class CreatePost extends React.Component { post.message, false, (data) => { + if (data.response === 'not implemented') { + this.sendMessage(post); + return; + } + PostStore.storeDraft(data.channel_id, null); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); @@ -116,63 +136,70 @@ export default class CreatePost extends React.Component { } }, (err) => { - const state = {}; - state.serverError = err.message; - state.submitting = false; - this.setState(state); - } - ); - } else { - post.channel_id = this.state.channelId; - post.filenames = this.state.previews; - - const time = Utils.getTimestamp(); - const userId = UserStore.getCurrentId(); - post.pending_post_id = `${userId}:${time}`; - post.user_id = userId; - post.create_at = time; - post.root_id = this.state.rootId; - post.parent_id = this.state.parentId; - - const channel = ChannelStore.get(this.state.channelId); - - PostStore.storePendingPost(post); - PostStore.storeDraft(channel.id, null); - this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); - - Client.createPost(post, channel, - (data) => { - AsyncClient.getPosts(); - - const member = ChannelStore.getMember(channel.id); - member.msg_count = channel.total_msg_count; - member.last_viewed_at = Date.now(); - ChannelStore.setChannelMember(member); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST, - post: data - }); - }, - (err) => { - const state = {}; - - if (err.message === 'Invalid RootId parameter') { - if ($('#post_deleted').length > 0) { - $('#post_deleted').modal('show'); - } - PostStore.removePendingPost(post.pending_post_id); + if (err.sendMessage) { + this.sendMessage(post); } else { - post.state = Constants.POST_FAILED; - PostStore.updatePendingPost(post); + const state = {}; + state.serverError = err.message; + state.submitting = false; + this.setState(state); } - - state.submitting = false; - this.setState(state); } ); + } else { + this.sendMessage(post); } } + sendMessage(post) { + post.channel_id = this.state.channelId; + post.filenames = this.state.previews; + + const time = Utils.getTimestamp(); + const userId = UserStore.getCurrentId(); + post.pending_post_id = `${userId}:${time}`; + post.user_id = userId; + post.create_at = time; + post.root_id = this.state.rootId; + post.parent_id = this.state.parentId; + + const channel = ChannelStore.get(this.state.channelId); + + PostStore.storePendingPost(post); + PostStore.storeDraft(channel.id, null); + this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); + + Client.createPost(post, channel, + (data) => { + AsyncClient.getPosts(); + + const member = ChannelStore.getMember(channel.id); + member.msg_count = channel.total_msg_count; + member.last_viewed_at = Date.now(); + ChannelStore.setChannelMember(member); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST, + post: data + }); + }, + (err) => { + const state = {}; + + if (err.message === 'Invalid RootId parameter') { + if ($('#post_deleted').length > 0) { + $('#post_deleted').modal('show'); + } + PostStore.removePendingPost(post.pending_post_id); + } else { + post.state = Constants.POST_FAILED; + PostStore.updatePendingPost(post); + } + + state.submitting = false; + this.setState(state); + } + ); + } postMsgKeyPress(e) { if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { e.preventDefault(); @@ -194,10 +221,9 @@ export default class CreatePost extends React.Component { PostStore.storeCurrentDraft(draft); } resizePostHolder() { - const height = $(window).height() - $(ReactDOM.findDOMNode(this.refs.topDiv)).height() - 50; + const height = this.state.windowHeight - $(ReactDOM.findDOMNode(this.refs.topDiv)).height() - 50; $('.post-list-holder-by-time').css('height', `${height}px`); - $(window).trigger('resize'); - if ($(window).width() > 960) { + if (this.state.windowWidth > 960) { $('#post_textbox').focus(); } } @@ -274,9 +300,11 @@ export default class CreatePost extends React.Component { componentDidMount() { ChannelStore.addChangeListener(this.onChange); this.resizePostHolder(); + window.addEventListener('resize', this.handleResize); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onChange); + window.removeEventListener('resize', this.handleResize); } onChange() { const channelId = ChannelStore.getCurrentId(); diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index 90d9696e7..b259b3c18 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -70,7 +70,7 @@ export default class EditPostModal extends React.Component { refocusId: options.refocusId || '' }); - $(React.findDOMNode(this.refs.modal)).modal('show'); + $(ReactDOM.findDOMNode(this.refs.modal)).modal('show'); } componentDidMount() { var self = this; @@ -92,7 +92,7 @@ export default class EditPostModal extends React.Component { $('#edit_textbox').get(0).focus(); }); - $(React.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() { + $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() { if (self.state.refocusId !== '') { setTimeout(() => { $(self.state.refocusId).get(0).focus(); diff --git a/web/react/components/email_verify.jsx b/web/react/components/email_verify.jsx index 940b01f8d..9c07853b7 100644 --- a/web/react/components/email_verify.jsx +++ b/web/react/components/email_verify.jsx @@ -19,10 +19,10 @@ export default class EmailVerify extends React.Component { var resend = ''; var resendConfirm = ''; if (this.props.isVerified === 'true') { - title = global.window.config.SiteName + ' Email Verified'; + title = global.window.mm_config.SiteName + ' Email Verified'; body = <p>Your email has been verified! Click <a href={this.props.teamURL + '?email=' + this.props.userEmail}>here</a> to log in.</p>; } else { - title = global.window.config.SiteName + ': You are almost done'; + title = global.window.mm_config.SiteName + ': You are almost done'; body = <p>Please verify your email address. Check your inbox for an email.</p>; resend = ( <button diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index c6dff6550..4d4e8390c 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -10,9 +10,12 @@ export default class FileAttachment extends React.Component { super(props); this.loadFiles = this.loadFiles.bind(this); + this.playGif = this.playGif.bind(this); + this.stopGif = this.stopGif.bind(this); + this.addBackgroundImage = this.addBackgroundImage.bind(this); this.canSetState = false; - this.state = {fileSize: -1}; + this.state = {fileSize: -1, mime: '', playing: false, loading: false, format: ''}; } componentDidMount() { this.loadFiles(); @@ -28,18 +31,12 @@ export default class FileAttachment extends React.Component { var filename = this.props.filename; if (filename) { - var fileInfo = utils.splitFileLocation(filename); + var fileInfo = this.getFileInfoFromName(filename); var type = utils.getFileType(fileInfo.ext); - // 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; - if (type === 'image') { var self = this; // Need this reference since we use the given "this" - $('<img/>').attr('src', fileInfo.path + '_thumb.jpg').load(function loadWrapper(path, name) { + $('<img/>').attr('src', fileInfo.path + '_thumb.jpg?' + utils.getSessionIndex()).load(function loadWrapper(path, name) { return function loader() { $(this).remove(); if (name in self.refs) { @@ -58,11 +55,7 @@ export default class FileAttachment extends React.Component { $(imgDiv).addClass('normal'); } - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - $(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)'); + self.addBackgroundImage(name, path); } }; }(fileInfo.path, filename)); @@ -93,6 +86,75 @@ export default class FileAttachment extends React.Component { return true; } + playGif(e, filename) { + var img = new Image(); + var fileUrl = utils.getFileUrl(filename); + + this.setState({loading: true}); + img.load(fileUrl); + img.onload = () => { + var state = {playing: true, loading: false}; + + switch (true) { + case img.width > img.height: + state.format = 'landscape'; + break; + case img.height > img.width: + state.format = 'portrait'; + break; + default: + state.format = 'quadrat'; + break; + } + + this.setState(state); + + // keep displaying background image for a short moment while browser is + // loading gif, to prevent white background flashing through + setTimeout(() => this.removeBackgroundImage.bind(this)(filename), 100); + }; + img.onError = () => this.setState({loading: false}); + + e.stopPropagation(); + } + stopGif(e, filename) { + this.setState({playing: false}); + this.addBackgroundImage(filename); + e.stopPropagation(); + } + getFileInfoFromName(name) { + var fileInfo = utils.splitFileLocation(name); + + // 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; + } + addBackgroundImage(name, path) { + var fileUrl = path; + + if (name in this.refs) { + if (!path) { + fileUrl = this.getFileInfoFromName(name).path; + } + + var imgDiv = ReactDOM.findDOMNode(this.refs[name]); + var re1 = new RegExp(' ', 'g'); + var re2 = new RegExp('\\(', 'g'); + var re3 = new RegExp('\\)', 'g'); + var url = fileUrl.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); + + $(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg?' + utils.getSessionIndex() + ')'); + } + } + removeBackgroundImage(name) { + if (name in this.refs) { + $(ReactDOM.findDOMNode(this.refs[name])).css('background-image', 'initial'); + } + } render() { var filename = this.props.filename; @@ -100,15 +162,71 @@ export default class FileAttachment extends React.Component { var fileUrl = utils.getFileUrl(filename); var type = utils.getFileType(fileInfo.ext); - var thumbnail; - if (type === 'image') { - thumbnail = ( + var playbackControls = ''; + var loadedFile = ''; + var loadingIndicator = ''; + if (this.state.mime === 'image/gif') { + playbackControls = ( <div - ref={filename} - className='post__load' - style={{backgroundImage: 'url(/static/images/load.gif)'}} + className='file-playback-controls play' + onClick={(e) => this.playGif(e, filename)} + > + {"►"} + </div> + ); + } + if (this.state.playing) { + loadedFile = ( + <img + className={'file__loaded ' + this.state.format} + src={fileUrl} + /> + ); + playbackControls = ( + <div + className='file-playback-controls stop' + onClick={(e) => this.stopGif(e, filename)} + > + {"■"} + </div> + ); + } + if (this.state.loading) { + loadingIndicator = ( + <img + className='spinner file__loading' + src='/static/images/load.gif' /> ); + playbackControls = ''; + } + + var thumbnail; + if (type === 'image') { + if (this.state.playing) { + thumbnail = ( + <div + ref={filename} + className='post__load' + style={{backgroundImage: 'url(/static/images/load.gif)'}} + > + {playbackControls} + {loadedFile} + </div> + ); + } else { + thumbnail = ( + <div + ref={filename} + className='post__load' + style={{backgroundImage: 'url(/static/images/load.gif)'}} + > + {loadingIndicator} + {playbackControls} + {loadedFile} + </div> + ); + } } else { thumbnail = <div className={'file-icon ' + utils.getIconClassName(type)}/>; } @@ -119,7 +237,7 @@ export default class FileAttachment extends React.Component { filename, function success(data) { if (this.canSetState) { - this.setState({fileSize: parseInt(data.size, 10)}); + this.setState({fileSize: parseInt(data.size, 10), mime: data.mime}); } }.bind(this), function error() {} diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index a40ed1dcf..df5deb8bc 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -34,7 +34,7 @@ export default class FilePreview extends React.Component { if (filename.indexOf('/api/v1/files/get') !== -1) { filename = filename.split('/api/v1/files/get')[1]; } - filename = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename; + filename = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename + '?' + Utils.getSessionIndex(); if (type === 'image') { previews.push( diff --git a/web/react/components/file_upload_overlay.jsx b/web/react/components/file_upload_overlay.jsx index 4fcee6cb0..dbba00022 100644 --- a/web/react/components/file_upload_overlay.jsx +++ b/web/react/components/file_upload_overlay.jsx @@ -12,9 +12,21 @@ export default class FileUploadOverlay extends React.Component { return ( <div className={overlayClass}> - <div> - <i className='fa fa-upload'></i> - <span>Drop a file to upload it.</span> + <div className='overlay__indent'> + <div className='overlay__circle'> + <img + className='overlay__files' + src='/static/images/filesOverlay.png' + alt='Files' + /> + <span><i className='fa fa-upload'></i>{'Drop a file to upload it.'}</span> + <img + className='overlay__logo' + src='/static/images/logoWhite.png' + width='100' + alt='Logo' + /> + </div> </div> </div> ); diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index 90290099d..86a4b04cf 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -21,7 +21,7 @@ export default class InviteMemberModal extends React.Component { emailErrors: {}, firstNameErrors: {}, lastNameErrors: {}, - emailEnabled: global.window.config.SendEmailNotifications === 'true' + emailEnabled: global.window.mm_config.SendEmailNotifications === 'true' }; } @@ -260,6 +260,12 @@ export default class InviteMemberModal extends React.Component { var content = null; var sendButton = null; + + var sendButtonLabel = 'Send Invitation'; + if (this.state.inviteIds.length > 1) { + sendButtonLabel = 'Send Invitations'; + } + if (this.state.emailEnabled) { content = ( <div> @@ -281,7 +287,7 @@ export default class InviteMemberModal extends React.Component { onClick={this.handleSubmit} type='button' className='btn btn-primary' - >Send Invitations</button> + >{sendButtonLabel}</button> ); } else { var teamInviteLink = null; diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx index c982d57ca..108735caf 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -16,7 +16,7 @@ export default class Login extends React.Component { } handleSubmit(e) { e.preventDefault(); - let state = {}; + var state = {}; const name = this.props.teamName; if (!name) { @@ -49,8 +49,7 @@ export default class Login extends React.Component { this.setState(state); Client.loginByEmail(name, email, password, - function loggedIn(data) { - UserStore.setCurrentUser(data); + () => { UserStore.setLastEmail(email); const redirect = Utils.getUrlParameter('redirect'); @@ -60,7 +59,7 @@ export default class Login extends React.Component { window.location.href = '/' + name + '/channels/town-square'; } }, - function loginFailed(err) { + (err) => { if (err.message === 'Login failed because email address has not been verified') { window.location.href = '/verify_email?teamname=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email); return; @@ -68,7 +67,7 @@ export default class Login extends React.Component { state.serverError = err.message; this.valid = false; this.setState(state); - }.bind(this) + } ); } render() { @@ -95,7 +94,7 @@ export default class Login extends React.Component { } let loginMessage = []; - if (global.window.config.EnableSignUpWithGitLab === 'true') { + if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { loginMessage.push( <a className='btn btn-custom-login gitlab' @@ -124,7 +123,7 @@ export default class Login extends React.Component { } let emailSignup; - if (global.window.config.EnableSignUpWithEmail === 'true') { + if (global.window.mm_config.EnableSignUpWithEmail === 'true') { emailSignup = ( <div> <div className={'form-group' + errorClass}> @@ -186,7 +185,7 @@ export default class Login extends React.Component { <div className='signup-team__container'> <h5 className='margin--less'>Sign in to:</h5> <h2 className='signup-team__name'>{teamDisplayName}</h2> - <h2 className='signup-team__subdomain'>on {global.window.config.SiteName}</h2> + <h2 className='signup-team__subdomain'>on {global.window.mm_config.SiteName}</h2> <form onSubmit={this.handleSubmit}> {verifiedBox} <div className={'form-group' + errorClass}> diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx index 5c3695ad4..8ed94680e 100644 --- a/web/react/components/member_list_item.jsx +++ b/web/react/components/member_list_item.jsx @@ -105,7 +105,7 @@ export default class MemberListItem extends React.Component { <div className='row member-div'> <img className='post-profile-img pull-left' - src={'/api/v1/users/' + member.id + '/image?time=' + timestamp} + src={'/api/v1/users/' + member.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} height='36' width='36' /> diff --git a/web/react/components/member_list_team_item.jsx b/web/react/components/member_list_team_item.jsx index 3af1d3800..14db05cdb 100644 --- a/web/react/components/member_list_team_item.jsx +++ b/web/react/components/member_list_team_item.jsx @@ -169,7 +169,7 @@ export default class MemberListTeamItem extends React.Component { <div className='row member-div'> <img className='post-profile-img pull-left' - src={`/api/v1/users/${user.id}/image?time=${timestamp}`} + src={`/api/v1/users/${user.id}/image?time=${timestamp}&${Utils.getSessionIndex()}`} height='36' width='36' /> diff --git a/web/react/components/mention.jsx b/web/react/components/mention.jsx index aeed724a8..050887c6f 100644 --- a/web/react/components/mention.jsx +++ b/web/react/components/mention.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. var UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); export default class Mention extends React.Component { constructor(props) { @@ -25,7 +26,7 @@ export default class Mention extends React.Component { <span> <img className='mention-img' - src={'/api/v1/users/' + this.props.id + '/image?time=' + timestamp} + src={'/api/v1/users/' + this.props.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} /> </span> ); diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index a20c5cad5..a0084ad30 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -83,7 +83,7 @@ export default class MoreChannels extends React.Component { moreChannels = <LoadingScreen />; } else if (channels.length) { moreChannels = ( - <table className='more-channel-table table'> + <table className='more-table table'> <tbody> {channels.map(function cMap(channel, index) { var joinButton; @@ -108,8 +108,8 @@ export default class MoreChannels extends React.Component { return ( <tr key={channel.id}> <td> - <p className='more-channel-name'>{channel.display_name}</p> - <p className='more-channel-description'>{channel.description}</p> + <p className='more-name'>{channel.display_name}</p> + <p className='more-description'>{channel.description}</p> </td> <td className='td--action'> {joinButton} diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 08b64de8b..41746d1d7 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -31,7 +31,7 @@ export default class MoreDirectChannels extends React.Component { getUsersFromStore() { const currentId = UserStore.getCurrentId(); - const profiles = UserStore.getProfiles(); + const profiles = UserStore.getActiveOnlyProfiles(); const users = []; for (const id in profiles) { @@ -142,7 +142,6 @@ export default class MoreDirectChannels extends React.Component { details.push( <span key={`${user.nickname}__nickname`} - className='nickname' > {separator + user.nickname} </span> @@ -170,50 +169,61 @@ export default class MoreDirectChannels extends React.Component { } return ( - <li - key={user.id} - className='direct-channel' - > - <div className='col-xs-1 image-div'> + <tr key={'direct-channel-row-user' + user.id}> + <td + key={user.id} + className='direct-channel' + > <img - className='profile-image' - src={`/api/v1/users/${user.id}/image?time=${user.update_at}`} + className='profile-img pull-left' + width='38' + height='38' + src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`} /> - </div> - <div className='col-xs-9'> - <div className='username'> + <div className='more-name'> {user.username} </div> - <div> + <div className='more-description'> {details} </div> - </div> - <div className='col-xs-2 btn-div'> + </td> + <td className='td--action lg'> {joinButton} - </div> - </li> + </td> + </tr> ); } + componentDidUpdate(prevProps) { + if (!prevProps.show && this.props.show) { + $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300); + if ($(window).width() > 768) { + $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); + } + } + } + render() { if (!this.props.show) { return null; } let users = this.state.users; - if (this.state.filter !== '') { + if (this.state.filter) { + const filter = this.state.filter.toLowerCase(); + users = users.filter((user) => { - return user.username.indexOf(this.state.filter) !== -1 || - user.first_name.indexOf(this.state.filter) !== -1 || - user.last_name.indexOf(this.state.filter) !== -1 || - user.nickname.indexOf(this.state.filter) !== -1; + return user.username.toLowerCase().indexOf(filter) !== -1 || + user.first_name.toLowerCase().indexOf(filter) !== -1 || + user.last_name.toLowerCase().indexOf(filter) !== -1 || + user.nickname.toLowerCase().indexOf(filter) !== -1; }); } const userEntries = users.map(this.createRowForUser); if (userEntries.length === 0) { - userEntries.push(<li key='no-users-found'>{'No users found :('}</li>); + userEntries.push(<tr key='no-users-found'><td>{'No users found :('}</td></tr>); } let memberString = 'Member'; @@ -232,26 +242,35 @@ export default class MoreDirectChannels extends React.Component { <Modal className='modal-direct-channels' show={this.props.show} - bsSize='large' onHide={this.handleHide} > <Modal.Header closeButton={true}> - <Modal.Title>{'More Direct Messages'}</Modal.Title> + <Modal.Title>{'Direct Messages'}</Modal.Title> </Modal.Header> <Modal.Body> - <div> - <input - ref='filter' - className='form-control filter-textbox' - placeholder='Search members' - onInput={this.handleFilterChange} - style={{width: '200px', display: 'inline'}} - /> - <span className='member-count pull-right'>{count}</span> + <div className='row filter-row'> + <div className='col-sm-6'> + <input + ref='filter' + className='form-control filter-textbox' + placeholder='Search members' + onInput={this.handleFilterChange} + /> + </div> + <div className='col-sm-6'> + <span className='member-count'>{count}</span> + </div> + </div> + <div + ref='userList' + className='user-list' + > + <table className='more-table table'> + <tbody> + {userEntries} + </tbody> + </table> </div> - <ul className='user-list'> - {userEntries} - </ul> </Modal.Body> <Modal.Footer> <button diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx index 569942390..1bd23c55c 100644 --- a/web/react/components/msg_typing.jsx +++ b/web/react/components/msg_typing.jsx @@ -1,8 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SocketStore = require('../stores/socket_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); +const SocketStore = require('../stores/socket_store.jsx'); +const UserStore = require('../stores/user_store.jsx'); + +const Constants = require('../utils/constants.jsx'); +const SocketEvents = Constants.SocketEvents; export default class MsgTyping extends React.Component { constructor(props) { @@ -33,9 +36,9 @@ export default class MsgTyping extends React.Component { } onChange(msg) { - if (msg.action === 'typing' && - this.props.channelId === msg.channel_id && - this.props.parentId === msg.props.parent_id) { + if (msg.action === SocketEvents.TYPING && + this.props.channelId === msg.channel_id && + this.props.parentId === msg.props.parent_id) { this.lastTime = new Date().getTime(); var username = 'Someone'; @@ -52,7 +55,7 @@ export default class MsgTyping extends React.Component { } }.bind(this), 3000); } - } else if (msg.action === 'posted' && msg.channel_id === this.props.channelId) { + } else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) { this.setState({text: ''}); } } diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 1cb13bbe5..2b68645e5 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -152,7 +152,7 @@ export default class NavbarDropdown extends React.Component { sysAdminLink = ( <li> <a - href='/admin_console' + href={'/admin_console?' + Utils.getSessionIndex()} > {'System Console'} </a> @@ -178,7 +178,7 @@ export default class NavbarDropdown extends React.Component { }); } - if (global.window.config.EnableTeamCreation === 'true') { + if (global.window.mm_config.EnableTeamCreation === 'true') { teams.push( <li key='newTeam_li'> <a diff --git a/web/react/components/password_reset_form.jsx b/web/react/components/password_reset_form.jsx index 217f1b393..b452c40b7 100644 --- a/web/react/components/password_reset_form.jsx +++ b/web/react/components/password_reset_form.jsx @@ -61,7 +61,7 @@ export default class PasswordResetForm extends React.Component { <div className='signup-team__container'> <h3>Password Reset</h3> <form onSubmit={this.handlePasswordReset}> - <p>{'Enter a new password for your ' + this.props.teamDisplayName + ' ' + global.window.config.SiteName + ' account.'}</p> + <p>{'Enter a new password for your ' + this.props.teamDisplayName + ' ' + global.window.mm_config.SiteName + ' account.'}</p> <div className={formClass}> <input type='password' diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 16ae693fa..155e88600 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -35,13 +35,20 @@ export default class PopoverListMembers extends React.Component { const teamMembers = UserStore.getProfilesUsernameMap(); if (members && teamMembers) { - members.sort(function compareByLocal(a, b) { + members.sort((a, b) => { return a.username.localeCompare(b.username); }); - members.forEach(function addMemberElement(m) { + members.forEach((m, i) => { if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { - popoverHtml.push(<div className='text--nowrap'>{m.username}</div>); + popoverHtml.push( + <div + className='text--nowrap' + key={'popover-member-' + i} + > + {m.username} + </div> + ); count++; } }); @@ -57,8 +64,15 @@ export default class PopoverListMembers extends React.Component { <OverlayTrigger trigger='click' placement='bottom' - rootClose='true' - overlay={<Popover title='Members'>{popoverHtml}</Popover>} + rootClose={true} + overlay={ + <Popover + title='Members' + id='member-list-popover' + > + {popoverHtml} + </Popover> + } > <div id='member_popover'> <div> diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index 64d6776b4..dedac8951 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -120,6 +120,10 @@ export default class Post extends React.Component { var parentPost = this.props.parentPost; var posts = this.props.posts; + if (!post.props) { + post.props = {}; + } + var type = 'Post'; if (post.root_id && post.root_id.length > 0) { type = 'Comment'; @@ -140,7 +144,7 @@ export default class Post extends React.Component { } var currentUserCss = ''; - if (UserStore.getCurrentId() === post.user_id) { + if (UserStore.getCurrentId() === post.user_id && !post.props.from_webhook) { currentUserCss = 'current--user'; } @@ -158,8 +162,8 @@ export default class Post extends React.Component { var profilePic = null; if (!this.props.hideProfilePic) { - let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp; - if (post.props && post.props.from_webhook && global.window.config.EnablePostIconOverride === 'true') { + let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); + if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { if (post.props.override_icon_url) { src = post.props.override_icon_url; } @@ -200,6 +204,7 @@ export default class Post extends React.Component { posts={posts} handleCommentClick={this.handleCommentClick} retryPost={this.retryPost} + resize={this.props.resize} /> <PostInfo ref='info' @@ -223,5 +228,6 @@ Post.propTypes = { sameUser: React.PropTypes.bool, sameRoot: React.PropTypes.bool, hideProfilePic: React.PropTypes.bool, - isLastComment: React.PropTypes.bool + isLastComment: React.PropTypes.bool, + resize: React.PropTypes.func }; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 1db0b12e7..45eae8c6a 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -13,8 +13,12 @@ export default class PostBody extends React.Component { super(props); this.receivedYoutubeData = false; + this.isGifLoading = false; this.parseEmojis = this.parseEmojis.bind(this); + this.createEmbed = this.createEmbed.bind(this); + this.createGifEmbed = this.createGifEmbed.bind(this); + this.loadGif = this.loadGif.bind(this); this.createYoutubeEmbed = this.createYoutubeEmbed.bind(this); const linkData = Utils.extractLinks(this.props.post.message); @@ -46,6 +50,7 @@ export default class PostBody extends React.Component { componentDidUpdate() { this.parseEmojis(); + this.props.resize(); } componentWillReceiveProps(nextProps) { @@ -53,6 +58,52 @@ export default class PostBody extends React.Component { this.setState({links: linkData.links, message: linkData.text}); } + createEmbed(link) { + let embed = this.createYoutubeEmbed(link); + + if (embed != null) { + return embed; + } + + embed = this.createGifEmbed(link); + + return embed; + } + + loadGif(src) { + if (this.isGifLoading) { + return; + } + + this.isGifLoading = true; + + const gif = new Image(); + gif.src = src; + gif.onload = ( + () => { + this.setState({gifLoaded: true}); + } + ); + } + + createGifEmbed(link) { + if (link.substring(link.length - 4) !== '.gif') { + return null; + } + + if (!this.state.gifLoaded) { + this.loadGif(link); + return null; + } + + return ( + <img + className='gif-div' + src={link} + /> + ); + } + handleYoutubeTime(link) { const timeRegex = /[\\?&]t=([0-9hms]+)/; @@ -116,15 +167,15 @@ export default class PostBody extends React.Component { } var metadata = data.items[0].snippet; this.receivedYoutubeData = true; - this.setState({youtubeUploader: metadata.channelTitle, youtubeTitle: metadata.title}); + this.setState({youtubeTitle: metadata.title}); } - if (global.window.config.GoogleDeveloperKey && !this.receivedYoutubeData) { + if (global.window.mm_config.GoogleDeveloperKey && !this.receivedYoutubeData) { $.ajax({ async: true, url: 'https://www.googleapis.com/youtube/v3/videos', type: 'GET', - data: {part: 'snippet', id: youtubeId, key: global.window.config.GoogleDeveloperKey}, + data: {part: 'snippet', id: youtubeId, key: global.window.mm_config.GoogleDeveloperKey}, success: success.bind(this) }); } @@ -134,18 +185,12 @@ export default class PostBody extends React.Component { header = header + ' - '; } - let uploader = this.state.youtubeUploader; - if (!uploader) { - uploader = 'unknown'; - } - return ( <div className='post-comment'> <h4> <span className='video-type'>{header}</span> <span className='video-title'><a href={link}>{this.state.youtubeTitle}</a></span> </h4> - <h4 className='video-uploader'>{uploader}</h4> <div className='video-div embed-responsive-item' id={youtubeId} @@ -253,7 +298,7 @@ export default class PostBody extends React.Component { let embed; if (filenames.length === 0 && this.state.links) { - embed = this.createYoutubeEmbed(this.state.links[0]); + embed = this.createEmbed(this.state.links[0]); } let fileAttachmentHolder = ''; @@ -293,5 +338,6 @@ PostBody.propTypes = { post: React.PropTypes.object.isRequired, parentPost: React.PropTypes.object, retryPost: React.PropTypes.func.isRequired, - handleCommentClick: React.PropTypes.func.isRequired + handleCommentClick: React.PropTypes.func.isRequired, + resize: React.PropTypes.func.isRequired }; diff --git a/web/react/components/post_header.jsx b/web/react/components/post_header.jsx index 0ba5ce6b5..45e60c767 100644 --- a/web/react/components/post_header.jsx +++ b/web/react/components/post_header.jsx @@ -16,7 +16,7 @@ export default class PostHeader extends React.Component { let botIndicator; if (post.props && post.props.from_webhook) { - if (post.props.override_username && global.window.config.EnablePostUsernameOverride === 'true') { + if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { userProfile = ( <UserProfile userId={post.user_id} diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index a95095ff6..36260d77c 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -150,7 +150,7 @@ export default class PostInfo extends React.Component { <ul className='post-header post-info'> <li className='post-header-col'> <OverlayTrigger - delayShow='500' + delayShow={500} container={this} placement='top' overlay={tooltip} diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index 29728d368..3ceef478c 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -1,20 +1,24 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostStore = require('../stores/post_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var PreferenceStore = require('../stores/preference_store.jsx'); -var UserProfile = require('./user_profile.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var Post = require('./post.jsx'); -var LoadingScreen = require('./loading_screen.jsx'); -var SocketStore = require('../stores/socket_store.jsx'); -var utils = require('../utils/utils.jsx'); -var Client = require('../utils/client.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +const Post = require('./post.jsx'); +const UserProfile = require('./user_profile.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); +const LoadingScreen = require('./loading_screen.jsx'); + +const PostStore = require('../stores/post_store.jsx'); +const ChannelStore = require('../stores/channel_store.jsx'); +const UserStore = require('../stores/user_store.jsx'); +const SocketStore = require('../stores/socket_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); + +const Utils = require('../utils/utils.jsx'); +const Client = require('../utils/client.jsx'); +const Constants = require('../utils/constants.jsx'); +const ActionTypes = Constants.ActionTypes; +const SocketEvents = Constants.SocketEvents; + +const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); export default class PostList extends React.Component { constructor(props) { @@ -36,11 +40,14 @@ export default class PostList extends React.Component { this.loadFirstPosts = this.loadFirstPosts.bind(this); this.activate = this.activate.bind(this); this.deactivate = this.deactivate.bind(this); - this.resize = this.resize.bind(this); + this.handleResize = this.handleResize.bind(this); + this.resizePostList = this.resizePostList.bind(this); + this.updateScroll = this.updateScroll.bind(this); const state = this.getStateFromStores(props.channelId); state.numToDisplay = Constants.POST_CHUNK_SIZE; state.isFirstLoadComplete = false; + state.windowHeight = Utils.windowHeight(); this.state = state; } @@ -58,7 +65,7 @@ export default class PostList extends React.Component { } } - postList.order.sort(function postSort(a, b) { + postList.order.sort((a, b) => { if (postList.posts[a].create_at > postList.posts[b].create_at) { return -1; } @@ -82,7 +89,7 @@ export default class PostList extends React.Component { } return { - postList: postList + postList }; } componentDidMount() { @@ -111,12 +118,7 @@ export default class PostList extends React.Component { const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - $(window).resize(() => { - this.resize(); - if (!this.scrolled) { - this.scrollToBottom(); - } - }); + window.addEventListener('resize', this.handleResize); postHolder.on('scroll', () => { const position = postHolder.scrollTop() + postHolder.height() + 14; @@ -150,7 +152,7 @@ export default class PostList extends React.Component { this.loadFirstPosts(this.props.channelId); } - this.resize(); + this.resizePostList(); this.onChange(); this.scrollToBottom(); } @@ -160,7 +162,9 @@ export default class PostList extends React.Component { SocketStore.removeChangeListener(this.onSocketChange); PreferenceStore.removeChangeListener(this.onTimeChange); $('body').off('click.userpopover'); - $(window).off('resize'); + + window.removeEventListener('resize', this.handleResize); + var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); postHolder.off('scroll'); } @@ -169,6 +173,13 @@ export default class PostList extends React.Component { return; } + if (prevState.windowHeight !== this.state.windowHeight) { + this.resizePostList(); + if (!this.scrolled) { + this.scrollToBottom(); + } + } + $('.post-list__content div .post').removeClass('post--last'); $('.post-list__content div:last-child .post').addClass('post--last'); @@ -195,10 +206,11 @@ export default class PostList extends React.Component { this.scrollToBottom(); // there's a new post and - // it's by the user and not a comment + // it's by the user (and not from their webhook) and not a comment } else if (isNewPost && userId === firstPost.user_id && - !utils.isComment(firstPost)) { + !firstPost.props.from_webhook && + !Utils.isComment(firstPost)) { this.scrollToBottom(true); // the user clicked 'load more messages' @@ -227,10 +239,20 @@ export default class PostList extends React.Component { this.deactivate(); } } - resize() { + updateScroll() { + if (!this.scrolled) { + this.scrollToBottom(); + } + } + handleResize() { + this.setState({ + windowHeight: Utils.windowHeight() + }); + } + resizePostList() { const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); if ($('#create_post').length > 0) { - const height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50; + const height = this.state.windowHeight - $('#create_post').height() - $('#error_bar').outerHeight() - 50; postHolder.css('height', height + 'px'); } } @@ -263,46 +285,34 @@ export default class PostList extends React.Component { Client.getPosts( id, PostStore.getLatestUpdate(id), - function success() { + () => { this.loadInProgress = false; this.setState({isFirstLoadComplete: true}); - }.bind(this), - function fail() { + }, + () => { this.loadInProgress = false; this.setState({isFirstLoadComplete: true}); - }.bind(this) + } ); } onChange() { var newState = this.getStateFromStores(this.props.channelId); - if (!utils.areStatesEqual(newState.postList, this.state.postList)) { + if (!Utils.areStatesEqual(newState.postList, this.state.postList)) { this.setState(newState); } } onSocketChange(msg) { - var post; - if (msg.action === 'posted' || msg.action === 'post_edited') { - post = JSON.parse(msg.props.post); - PostStore.storePost(post); - } else if (msg.action === 'post_deleted') { + if (msg.action === SocketEvents.POST_DELETED) { var activeRoot = $(document.activeElement).closest('.comment-create-body')[0]; var activeRootPostId = ''; if (activeRoot && activeRoot.id.length > 0) { activeRootPostId = activeRoot.id; } - post = JSON.parse(msg.props.post); - - PostStore.storeUnseenDeletedPost(post); - PostStore.removePost(post, true); - PostStore.emitChange(); - if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() !== msg.user_id) { $('#post_deleted').modal('show'); } - } else if (msg.action === 'new_user') { - AsyncClient.getProfiles(); } } onTimeChange() { @@ -318,7 +328,7 @@ export default class PostList extends React.Component { } } createDMIntroMessage(channel) { - var teammate = utils.getDirectTeammate(channel.id); + var teammate = Utils.getDirectTeammate(channel.id); if (teammate) { var teammateName = teammate.username; @@ -331,7 +341,7 @@ export default class PostList extends React.Component { <div className='post-profile-img__container channel-intro-img'> <img className='post-profile-img' - src={'/api/v1/users/' + teammate.id + '/image?time=' + teammate.update_at} + src={'/api/v1/users/' + teammate.id + '/image?time=' + teammate.update_at + '&' + Utils.getSessionIndex()} height='50' width='50' /> @@ -352,7 +362,7 @@ export default class PostList extends React.Component { data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>Set a description + <i className='fa fa-pencil'></i>{'Set a description'} </a> </div> ); @@ -378,13 +388,13 @@ export default class PostList extends React.Component { createDefaultIntroMessage(channel) { return ( <div className='channel-intro'> - <h4 className='channel-intro__title'>Beginning of {channel.display_name}</h4> + <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> <p className='channel-intro__content'> - Welcome to {channel.display_name}! + {'Welcome to ' + channel.display_name + '!'} <br/><br/> - This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know. + {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} <br/><br/> - To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”. + {'To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”.'} <br/> </p> </div> @@ -393,7 +403,7 @@ export default class PostList extends React.Component { createOffTopicIntroMessage(channel) { return ( <div className='channel-intro'> - <h4 className='channel-intro__title'>Beginning of {channel.display_name}</h4> + <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> <p className='channel-intro__content'> {'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'} <br/> @@ -407,7 +417,7 @@ export default class PostList extends React.Component { data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>Set a description + <i className='fa fa-pencil'></i>{'Set a description'} </a> <a className='intro-links' @@ -415,7 +425,7 @@ export default class PostList extends React.Component { data-toggle='modal' data-target='#channel_invite' > - <i className='fa fa-user-plus'></i>Invite others to this channel + <i className='fa fa-user-plus'></i>{'Invite others to this channel'} </a> </div> ); @@ -430,7 +440,7 @@ export default class PostList extends React.Component { var members = ChannelStore.getExtraInfo(channel.id).members; for (var i = 0; i < members.length; i++) { - if (utils.isAdmin(members[i].roles)) { + if (Utils.isAdmin(members[i].roles)) { return members[i].username; } } @@ -451,14 +461,14 @@ export default class PostList extends React.Component { var createMessage; if (creatorName === '') { - createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + utils.displayDate(channel.create_at) + '.'; + createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + Utils.displayDate(channel.create_at) + '.'; } else { - createMessage = (<span>This is the start of the <strong>{uiName}</strong> {uiType}, created by <strong>{creatorName}</strong> on <strong>{utils.displayDate(channel.create_at)}</strong></span>); + createMessage = (<span>This is the start of the <strong>{uiName}</strong> {uiType}, created by <strong>{creatorName}</strong> on <strong>{Utils.displayDate(channel.create_at)}</strong></span>); } return ( <div className='channel-intro'> - <h4 className='channel-intro__title'>Beginning of {uiName}</h4> + <h4 className='channel-intro__title'>{'Beginning of ' + uiName}</h4> <p className='channel-intro__content'> {createMessage} {memberMessage} @@ -473,7 +483,7 @@ export default class PostList extends React.Component { data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>Set a description + <i className='fa fa-pencil'></i>{'Set a description'} </a> <a className='intro-links' @@ -481,7 +491,7 @@ export default class PostList extends React.Component { data-toggle='modal' data-target='#channel_invite' > - <i className='fa fa-user-plus'></i>Invite others to this {uiType} + <i className='fa fa-user-plus'></i>{'Invite others to this ' + uiType} </a> </div> ); @@ -515,7 +525,7 @@ export default class PostList extends React.Component { if (prevPost) { sameUser = prevPost.user_id === post.user_id && post.create_at - prevPost.create_at <= 1000 * 60 * 5; - sameRoot = utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id); + sameRoot = Utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id); // hide the profile pic if: // the previous post was made by the same user as the current post, @@ -524,8 +534,8 @@ export default class PostList extends React.Component { // the current post is not from a webhook // and the previous post is not from a webhook if ((prevPost.user_id === post.user_id) && - !utils.isComment(prevPost) && - !utils.isComment(post) && + !Utils.isComment(prevPost) && + !Utils.isComment(post) && (!post.props || !post.props.from_webhook) && (!prevPost.props || !prevPost.props.from_webhook)) { hideProfilePic = true; @@ -534,7 +544,7 @@ export default class PostList extends React.Component { // check if it's the last comment in a consecutive string of comments on the same post // it is the last comment if it is last post in the channel or the next post has a different root post - var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); + var isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); var postCtl = ( <Post @@ -547,10 +557,11 @@ export default class PostList extends React.Component { posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} + resize={this.updateScroll} /> ); - let currentPostDay = utils.getDateForUnixTicks(post.create_at); + const currentPostDay = Utils.getDateForUnixTicks(post.create_at); if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { postCtls.push( <div @@ -566,9 +577,9 @@ export default class PostList extends React.Component { if (post.user_id !== userId && post.create_at > lastViewed && !renderedLastViewed) { renderedLastViewed = true; - // Temporary fix to solve ie10/11 rendering issue + // Temporary fix to solve ie11 rendering issue let newSeparatorId = ''; - if (!utils.isBrowserIE()) { + if (!Utils.isBrowserIE()) { newSeparatorId = 'new_message_' + this.props.channelId; } postCtls.push( @@ -580,7 +591,7 @@ export default class PostList extends React.Component { <hr className='separator__hr' /> - <div className='separator__text'>New Messages</div> + <div className='separator__text'>{'New Messages'}</div> </div> ); } @@ -646,7 +657,7 @@ export default class PostList extends React.Component { order = this.state.postList.order; } - var moreMessages = <p className='beginning-messages-text'>Beginning of Channel</p>; + var moreMessages = <p className='beginning-messages-text'>{'Beginning of Channel'}</p>; if (channel != null) { if (order.length >= this.state.numToDisplay) { moreMessages = ( @@ -656,7 +667,7 @@ export default class PostList extends React.Component { href='#' onClick={this.loadMorePosts} > - Load more messages + {'Load more messages'} </a> ); } else { diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index 402e64080..cfff04fa2 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -29,7 +29,7 @@ export default class RhsComment extends React.Component { var post = this.props.post; Client.createPost(post, post.channel_id, - function success(data) { + (data) => { AsyncClient.getPosts(post.channel_id); var channel = ChannelStore.get(post.channel_id); @@ -43,11 +43,11 @@ export default class RhsComment extends React.Component { post: data }); }, - function fail() { + () => { post.state = Constants.POST_FAILED; PostStore.updatePendingPost(post); this.forceUpdate(); - }.bind(this) + } ); post.state = Constants.POST_LOADING; @@ -84,7 +84,10 @@ export default class RhsComment extends React.Component { if (isOwner) { dropdownContents.push( - <li role='presentation'> + <li + role='presentation' + key='edit-button' + > <a href='#' role='menuitem' @@ -95,7 +98,7 @@ export default class RhsComment extends React.Component { data-postid={post.id} data-channelid={post.channel_id} > - Edit + {'Edit'} </a> </li> ); @@ -103,7 +106,10 @@ export default class RhsComment extends React.Component { if (isOwner || isAdmin) { dropdownContents.push( - <li role='presentation'> + <li + role='presentation' + key='delete-button' + > <a href='#' role='menuitem' @@ -114,7 +120,7 @@ export default class RhsComment extends React.Component { data-channelid={post.channel_id} data-comments={0} > - Delete + {'Delete'} </a> </li> ); @@ -162,7 +168,7 @@ export default class RhsComment extends React.Component { href='#' onClick={this.retryComment} > - Retry + {'Retry'} </a> ); } else if (post.state === Constants.POST_LOADING) { @@ -193,7 +199,7 @@ export default class RhsComment extends React.Component { <div className='post-profile-img__container'> <img className='post-profile-img' - src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp} + src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} height='36' width='36' /> @@ -213,14 +219,14 @@ export default class RhsComment extends React.Component { </li> </ul> <div className='post-body'> - <p className={postClass}> + <div className={postClass}> {loading} <div ref='message_holder' onClick={TextFormatting.handleClick} dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} /> - </p> + </div> {fileAttachment} </div> </div> diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index a9f1fcd30..deef389e2 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -121,7 +121,7 @@ export default class RhsRootPost extends React.Component { let botIndicator; if (post.props && post.props.from_webhook) { - if (post.props.override_username && global.window.config.EnablePostUsernameOverride === 'true') { + if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { userProfile = ( <UserProfile userId={post.user_id} @@ -134,8 +134,8 @@ export default class RhsRootPost extends React.Component { botIndicator = <li className='post-header-col post-header__name bot-indicator'>{'BOT'}</li>; } - let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp; - if (post.props && post.props.from_webhook && global.window.config.EnablePostIconOverride === 'true') { + let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); + if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { if (post.props.override_icon_url) { src = post.props.override_icon_url; } diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index 467d74681..bcdec2870 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -4,7 +4,7 @@ var PostStore = require('../stores/post_store.jsx'); var UserStore = require('../stores/user_store.jsx'); var PreferenceStore = require('../stores/preference_store.jsx'); -var utils = require('../utils/utils.jsx'); +var Utils = require('../utils/utils.jsx'); var SearchBox = require('./search_bar.jsx'); var CreateComment = require('./create_comment.jsx'); var RhsHeaderPost = require('./rhs_header_post.jsx'); @@ -20,8 +20,12 @@ export default class RhsThread extends React.Component { this.onChange = this.onChange.bind(this); this.onChangeAll = this.onChangeAll.bind(this); this.forceUpdateInfo = this.forceUpdateInfo.bind(this); + this.handleResize = this.handleResize.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.windowWidth = Utils.windowWidth(); + state.windowHeight = Utils.windowHeight(); + this.state = state; } getStateFromStores() { var postList = PostStore.getSelectedPost(); @@ -47,9 +51,7 @@ export default class RhsThread extends React.Component { PostStore.addChangeListener(this.onChangeAll); PreferenceStore.addChangeListener(this.forceUpdateInfo); this.resize(); - $(window).resize(function resize() { - this.resize(); - }.bind(this)); + window.addEventListener('resize', this.handleResize); } componentDidUpdate() { if ($('.post-right__scroll')[0]) { @@ -61,6 +63,7 @@ export default class RhsThread extends React.Component { PostStore.removeSelectedPostChangeListener(this.onChange); PostStore.removeChangeListener(this.onChangeAll); PreferenceStore.removeChangeListener(this.forceUpdateInfo); + window.removeEventListener('resize', this.handleResize); } forceUpdateInfo() { if (this.state.postList) { @@ -71,9 +74,15 @@ export default class RhsThread extends React.Component { } } } + handleResize() { + this.setState({ + windowWidth: Utils.windowWidth(), + windowHeight: Utils.windowHeight() + }); + } onChange() { var newState = this.getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!Utils.areStatesEqual(newState, this.state)) { this.setState(newState); } } @@ -103,15 +112,15 @@ export default class RhsThread extends React.Component { } var newState = this.getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!Utils.areStatesEqual(newState, this.state)) { this.setState(newState); } } resize() { - var height = $(window).height() - $('#error_bar').outerHeight() - 100; + var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100; $('.post-right__scroll').css('height', height + 'px'); $('.post-right__scroll').scrollTop(100000); - if ($(window).width() > 768) { + if (this.state.windowWidth > 768) { $('.post-right__scroll').perfectScrollbar(); $('.post-right__scroll').perfectScrollbar('update'); } diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx new file mode 100644 index 000000000..03c7b894c --- /dev/null +++ b/web/react/components/search_autocomplete.jsx @@ -0,0 +1,249 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const ChannelStore = require('../stores/channel_store.jsx'); +const KeyCodes = require('../utils/constants.jsx').KeyCodes; +const UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); + +const patterns = new Map([ + ['channels', /\b(?:in|channel):\s*(\S*)$/i], + ['users', /\bfrom:\s*(\S*)$/i] +]); + +export default class SearchAutocomplete extends React.Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleDocumentClick = this.handleDocumentClick.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + + this.completeWord = this.completeWord.bind(this); + this.updateSuggestions = this.updateSuggestions.bind(this); + + this.state = { + show: false, + mode: '', + filter: '', + selection: 0, + suggestions: new Map() + }; + } + + componentDidMount() { + $(document).on('click', this.handleDocumentClick); + } + + componentWillUnmount() { + $(document).off('click', this.handleDocumentClick); + } + + handleClick(value) { + this.completeWord(value); + } + + handleDocumentClick(e) { + const container = $(ReactDOM.findDOMNode(this.refs.container)); + + if (!(container.is(e.target) || container.has(e.target).length > 0)) { + this.setState({ + show: false + }); + } + } + + handleInputChange(textbox, text) { + const caret = Utils.getCaretPosition(textbox); + const preText = text.substring(0, caret); + + let mode = ''; + let filter = ''; + for (const [modeForPattern, pattern] of patterns) { + const result = pattern.exec(preText); + + if (result) { + mode = modeForPattern; + filter = result[1]; + break; + } + } + + if (mode !== this.state.mode || filter !== this.state.filter) { + this.updateSuggestions(mode, filter); + } + + this.setState({ + mode, + filter, + show: mode || filter + }); + } + + handleKeyDown(e) { + if (!this.state.show || this.state.suggestions.length === 0) { + return; + } + + if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) { + e.preventDefault(); + + let selection = this.state.selection; + + if (e.which === KeyCodes.UP) { + selection -= 1; + } else { + selection += 1; + } + + if (selection >= 0 && selection < this.state.suggestions.length) { + this.setState({ + selection + }); + } + } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { + e.preventDefault(); + + this.completeSelectedWord(); + } + } + + completeSelectedWord() { + if (this.state.mode === 'channels') { + this.completeWord(this.state.suggestions[this.state.selection].name); + } else if (this.state.mode === 'users') { + this.completeWord(this.state.suggestions[this.state.selection].username); + } + } + + completeWord(value) { + // add a space so that anything else typed doesn't interfere with the search flag + this.props.completeWord(this.state.filter, value + ' '); + + this.setState({ + show: false, + mode: '', + filter: '', + selection: 0 + }); + } + + updateSuggestions(mode, filter) { + let suggestions = []; + + if (mode === 'channels') { + let channels = ChannelStore.getAll(); + + if (filter) { + channels = channels.filter((channel) => channel.name.startsWith(filter)); + } + + channels.sort((a, b) => a.name.localeCompare(b.name)); + + suggestions = channels; + } else if (mode === 'users') { + let users = UserStore.getActiveOnlyProfileList(); + + if (filter) { + users = users.filter((user) => user.username.startsWith(filter)); + } + + users.sort((a, b) => a.username.localeCompare(b.username)); + + suggestions = users; + } + + let selection = this.state.selection; + + // keep the same user/channel selected if it's still visible as a suggestion + if (selection > 0 && this.state.suggestions.length > 0) { + // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects + const currentSelectionId = this.state.suggestions[selection].id; + let found = false; + + for (let i = 0; i < suggestions.length; i++) { + if (suggestions[i].id === currentSelectionId) { + selection = i; + found = true; + + break; + } + } + + if (!found) { + selection = 0; + } + } else { + selection = 0; + } + + this.setState({ + suggestions, + selection + }); + } + + render() { + if (!this.state.show || this.state.suggestions.length === 0) { + return null; + } + + let suggestions = []; + + if (this.state.mode === 'channels') { + suggestions = this.state.suggestions.map((channel, index) => { + let className = 'search-autocomplete__channel'; + if (this.state.selection === index) { + className += ' selected'; + } + + return ( + <div + key={channel.name} + ref={channel.name} + onClick={this.handleClick.bind(this, channel.name)} + className={className} + > + {channel.name} + </div> + ); + }); + } else if (this.state.mode === 'users') { + suggestions = this.state.suggestions.map((user, index) => { + let className = 'search-autocomplete__user'; + if (this.state.selection === index) { + className += ' selected'; + } + + return ( + <div + key={user.username} + ref={user.username} + onClick={this.handleClick.bind(this, user.username)} + className={className} + > + <img + className='profile-img' + src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} + /> + {user.username} + </div> + ); + }); + } + + return ( + <div + ref='container' + className='search-autocomplete' + > + {suggestions} + </div> + ); + } +} + +SearchAutocomplete.propTypes = { + completeWord: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 2e9764bd9..0da43e8cd 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -8,6 +8,8 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; +var Popover = ReactBootstrap.Popover; +var SearchAutocomplete = require('./search_autocomplete.jsx'); export default class SearchBar extends React.Component { constructor() { @@ -15,11 +17,17 @@ export default class SearchBar extends React.Component { this.mounted = false; this.onListenerChange = this.onListenerChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUserInput = this.handleUserInput.bind(this); + this.handleUserFocus = this.handleUserFocus.bind(this); + this.handleUserBlur = this.handleUserBlur.bind(this); this.performSearch = this.performSearch.bind(this); this.handleSubmit = this.handleSubmit.bind(this); + this.completeWord = this.completeWord.bind(this); - this.state = this.getSearchTermStateFromStores(); + const state = this.getSearchTermStateFromStores(); + state.focused = false; + this.state = state; } getSearchTermStateFromStores() { var term = PostStore.getSearchTerm() || ''; @@ -69,25 +77,44 @@ export default class SearchBar extends React.Component { results: null }); } + handleKeyDown(e) { + if (this.refs.autocomplete) { + this.refs.autocomplete.handleKeyDown(e); + } + } handleUserInput(e) { var term = e.target.value; PostStore.storeSearchTerm(term); PostStore.emitSearchTermChange(false); this.setState({searchTerm: term}); + + this.refs.autocomplete.handleInputChange(e.target, term); } handleMouseInput(e) { e.preventDefault(); } + handleUserBlur() { + this.setState({focused: false}); + } handleUserFocus(e) { e.target.select(); $('.search-bar__container').addClass('focused'); + + this.setState({focused: true}); } performSearch(terms, isMentionSearch) { if (terms.length) { this.setState({isSearching: true}); + + // append * if not present + let searchTerms = terms; + if (searchTerms.search(/\*\s*$/) === -1) { + searchTerms = searchTerms + '*'; + } + client.search( - terms, - function success(data) { + searchTerms, + (data) => { this.setState({isSearching: false}); if (utils.isMobile()) { ReactDOM.findDOMNode(this.refs.search).value = ''; @@ -98,11 +125,11 @@ export default class SearchBar extends React.Component { results: data, is_mention_search: isMentionSearch }); - }.bind(this), - function error(err) { + }, + (err) => { this.setState({isSearching: false}); AsyncClient.dispatchError(err, 'search'); - }.bind(this) + } ); } } @@ -110,11 +137,35 @@ export default class SearchBar extends React.Component { e.preventDefault(); this.performSearch(this.state.searchTerm.trim()); } + + completeWord(partialWord, word) { + const textbox = ReactDOM.findDOMNode(this.refs.search); + let text = textbox.value; + + const caret = utils.getCaretPosition(textbox); + const preText = text.substring(0, caret - partialWord.length); + const postText = text.substring(caret); + text = preText + word + postText; + + textbox.value = text; + utils.setCaretPosition(textbox, preText.length + word.length); + + PostStore.storeSearchTerm(text); + PostStore.emitSearchTermChange(false); + this.setState({searchTerm: text}); + } + render() { var isSearching = null; if (this.state.isSearching) { isSearching = <span className={'glyphicon glyphicon-refresh glyphicon-refresh-animate'}></span>; } + + let helpClass = 'search-help-popover'; + if (!this.state.searchTerm && this.state.focused) { + helpClass += ' visible'; + } + return ( <div> <div @@ -127,12 +178,13 @@ export default class SearchBar extends React.Component { className='search__clear' onClick={this.clearFocus} > - Cancel + {'Cancel'} </span> <form role='form' className='search__form relative-div' onSubmit={this.handleSubmit} + style={{overflow: 'visible'}} > <span className='glyphicon glyphicon-search sidebar__search-icon' /> <input @@ -142,10 +194,31 @@ export default class SearchBar extends React.Component { placeholder='Search' value={this.state.searchTerm} onFocus={this.handleUserFocus} + onBlur={this.handleUserBlur} onChange={this.handleUserInput} + onKeyDown={this.handleKeyDown} onMouseUp={this.handleMouseInput} /> {isSearching} + <SearchAutocomplete + ref='autocomplete' + completeWord={this.completeWord} + /> + <Popover + id='searchbar-help-popup' + placement='bottom' + className={helpClass} + > + <h4>{'Search Options'}</h4> + <ul> + <li> + <span>{'Use '}</span><b>{'"quotation marks"'}</b><span>{' to search for phrases'}</span> + </li> + <li> + <span>{'Use '}</span><b>{'from:'}</b><span>{' to find posts from specific users and '}</span><b>{'in:'}</b><span>{' to find posts in specific channels'}</span> + </li> + </ul> + </Popover> </form> </div> ); diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx index e55fd3752..30e15d0ad 100644 --- a/web/react/components/search_results.jsx +++ b/web/react/components/search_results.jsx @@ -4,7 +4,7 @@ var PostStore = require('../stores/post_store.jsx'); var UserStore = require('../stores/user_store.jsx'); var SearchBox = require('./search_bar.jsx'); -var utils = require('../utils/utils.jsx'); +var Utils = require('../utils/utils.jsx'); var SearchResultsHeader = require('./search_results_header.jsx'); var SearchResultsItem = require('./search_results_item.jsx'); @@ -20,18 +20,19 @@ export default class SearchResults extends React.Component { this.onChange = this.onChange.bind(this); this.resize = this.resize.bind(this); + this.handleResize = this.handleResize.bind(this); - this.state = getStateFromStores(); + const state = getStateFromStores(); + state.windowWidth = Utils.windowWidth(); + state.windowHeight = Utils.windowHeight(); + this.state = state; } componentDidMount() { this.mounted = true; PostStore.addSearchChangeListener(this.onChange); this.resize(); - var self = this; - $(window).resize(function resize() { - self.resize(); - }); + window.addEventListener('resize', this.handleResize); } componentDidUpdate() { @@ -41,22 +42,30 @@ export default class SearchResults extends React.Component { componentWillUnmount() { PostStore.removeSearchChangeListener(this.onChange); this.mounted = false; + window.removeEventListener('resize', this.handleResize); + } + + handleResize() { + this.setState({ + windowWidth: Utils.windowWidth(), + windowHeight: Utils.windowHeight() + }); } onChange() { if (this.mounted) { var newState = getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!Utils.areStatesEqual(newState, this.state)) { this.setState(newState); } } } resize() { - var height = $(window).height() - $('#error_bar').outerHeight() - 100; + var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100; $('#search-items-container').css('height', height + 'px'); $('#search-items-container').scrollTop(0); - if ($(window).width() > 768) { + if (this.state.windowWidth > 768) { $('#search-items-container').perfectScrollbar(); } } diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx index 75d2e7a45..d212e47a3 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -77,7 +77,7 @@ export default class SearchResultsItem extends React.Component { <div className='post-profile-img__container'> <img className='post-profile-img' - src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp} + src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()} height='36' width='36' /> diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx index 4f0fe3ed0..774f98a43 100644 --- a/web/react/components/setting_item_max.jsx +++ b/web/react/components/setting_item_max.jsx @@ -36,7 +36,7 @@ export default class SettingItemMax extends React.Component { if (this.props.width === 'full') { widthClass = 'col-sm-12'; } else { - widthClass = 'col-sm-9 col-sm-offset-3'; + widthClass = 'col-sm-10 col-sm-offset-2'; } return ( diff --git a/web/react/components/setting_picture.jsx b/web/react/components/setting_picture.jsx index 2f577fe39..b6bcb13a6 100644 --- a/web/react/components/setting_picture.jsx +++ b/web/react/components/setting_picture.jsx @@ -79,7 +79,7 @@ export default class SettingPicture extends React.Component { >Save</a> ); } - var helpText = 'Upload a profile picture in either JPG or PNG format, at least ' + global.window.config.ProfileWidth + 'px in width and ' + global.window.config.ProfileHeight + 'px height.'; + var helpText = 'Upload a profile picture in either JPG or PNG format, at least ' + global.window.mm_config.ProfileWidth + 'px in width and ' + global.window.mm_config.ProfileHeight + 'px height.'; var self = this; return ( diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx index 66568e1c8..4af46c35a 100644 --- a/web/react/components/settings_sidebar.jsx +++ b/web/react/components/settings_sidebar.jsx @@ -2,6 +2,10 @@ // See License.txt for license information. export default class SettingsSidebar extends React.Component { + componentDidUpdate() { + $('.settings-modal').find('.modal-body').scrollTop(0); + $('.settings-modal').find('.modal-body').perfectScrollbar('update'); + } constructor(props) { super(props); diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 89506c028..ed2c84057 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -2,7 +2,6 @@ // See License.txt for license information. const AsyncClient = require('../utils/async_client.jsx'); -const BrowserStore = require('../stores/browser_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); const Client = require('../utils/client.jsx'); const Constants = require('../utils/constants.jsx'); @@ -11,7 +10,6 @@ const NewChannelFlow = require('./new_channel_flow.jsx'); const MoreDirectChannels = require('./more_direct_channels.jsx'); const SearchBox = require('./search_bar.jsx'); const SidebarHeader = require('./sidebar_header.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); const TeamStore = require('../stores/team_store.jsx'); const UnreadChannelIndicator = require('./unread_channel_indicator.jsx'); const UserStore = require('../stores/user_store.jsx'); @@ -31,9 +29,10 @@ export default class Sidebar extends React.Component { this.onChange = this.onChange.bind(this); this.onScroll = this.onScroll.bind(this); - this.onResize = this.onResize.bind(this); this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this); this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this); + this.updateScrollbar = this.updateScrollbar.bind(this); + this.handleResize = this.handleResize.bind(this); this.showNewChannelModal = this.showNewChannelModal.bind(this); this.hideNewChannelModal = this.hideNewChannelModal.bind(this); @@ -46,8 +45,9 @@ export default class Sidebar extends React.Component { const state = this.getStateFromStores(); state.newChannelModalType = ''; - state.showMoreDirectChannelsModal = false; + state.showDirectChannelsModal = false; state.loadingDMChannel = -1; + state.windowWidth = Utils.windowWidth(); this.state = state; } @@ -129,15 +129,13 @@ export default class Sidebar extends React.Component { UserStore.addChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); TeamStore.addChangeListener(this.onChange); - SocketStore.addChangeListener(this.onSocketChange); PreferenceStore.addChangeListener(this.onChange); - $('.nav-pills__container').perfectScrollbar(); - this.updateTitle(); this.updateUnreadIndicators(); + this.updateScrollbar(); - $(window).on('resize', this.onResize); + window.addEventListener('resize', this.handleResize); } shouldComponentUpdate(nextProps, nextState) { if (!Utils.areStatesEqual(nextProps, this.props)) { @@ -152,117 +150,41 @@ export default class Sidebar extends React.Component { componentDidUpdate() { this.updateTitle(); this.updateUnreadIndicators(); + this.updateScrollbar(); } componentWillUnmount() { - $(window).off('resize', this.onResize); + window.removeEventListener('resize', this.handleResize); ChannelStore.removeChangeListener(this.onChange); UserStore.removeChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); TeamStore.removeChangeListener(this.onChange); - SocketStore.removeChangeListener(this.onSocketChange); PreferenceStore.removeChangeListener(this.onChange); } + handleResize() { + this.setState({ + windowWidth: Utils.windowWidth(), + windowHeight: Utils.windowHeight() + }); + } + updateScrollbar() { + if (this.state.windowWidth > 768) { + $('.nav-pills__container').perfectScrollbar(); + $('.nav-pills__container').perfectScrollbar('update'); + } + } onChange() { var newState = this.getStateFromStores(); if (!Utils.areStatesEqual(newState, this.state)) { this.setState(newState); } } - onSocketChange(msg) { - if (msg.action === 'posted') { - if (ChannelStore.getCurrentId() === msg.channel_id) { - if (window.isActive) { - AsyncClient.updateLastViewedAt(); - } - } else { - AsyncClient.getChannels(); - } - - if (UserStore.getCurrentId() !== msg.user_id) { - var mentions = []; - if (msg.props.mentions) { - mentions = JSON.parse(msg.props.mentions); - } - var channel = ChannelStore.get(msg.channel_id); - - const user = UserStore.getCurrentUser(); - const member = ChannelStore.getMember(msg.channel_id); - - var notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; - if (notifyLevel === 'default') { - notifyLevel = user.notify_props.desktop; - } - - if (notifyLevel === 'none') { - return; - } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== 'D') { - return; - } - - var username = 'Someone'; - if (UserStore.hasProfile(msg.user_id)) { - username = UserStore.getProfile(msg.user_id).username; - } - - var title = 'Posted'; - if (channel) { - title = channel.display_name; - } - - var repRegex = new RegExp('<br>', 'g'); - var post = JSON.parse(msg.props.post); - var msgProps = msg.props; - var notifyText = post.message.replace(repRegex, '\n').replace(/\n+/g, ' ').replace('<mention>', '').replace('</mention>', ''); - - if (notifyText.length > 50) { - notifyText = notifyText.substring(0, 49) + '...'; - } - - if (notifyText.length === 0) { - if (msgProps.image) { - Utils.notifyMe(title, username + ' uploaded an image', channel); - } else if (msgProps.otherFile) { - Utils.notifyMe(title, username + ' uploaded a file', channel); - } else { - Utils.notifyMe(title, username + ' did something new', channel); - } - } else { - Utils.notifyMe(title, username + ' wrote: ' + notifyText, channel); - } - if (!user.notify_props || user.notify_props.desktop_sound === 'true') { - Utils.ding(); - } - } - } else if (msg.action === 'viewed') { - if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) { - AsyncClient.getChannel(msg.channel_id); - } - } else if (msg.action === 'user_added') { - if (UserStore.getCurrentId() === msg.user_id) { - AsyncClient.getChannel(msg.channel_id); - } - } else if (msg.action === 'user_removed') { - if (msg.user_id === UserStore.getCurrentId()) { - AsyncClient.getChannels(true); - - if (msg.props.remover !== msg.user_id && msg.props.channel_id === ChannelStore.getCurrentId() && $('#removed_from_channel').length > 0) { - var sentState = {}; - sentState.channelName = ChannelStore.getCurrent().display_name; - sentState.remover = UserStore.getProfile(msg.props.remover).username; - - BrowserStore.setItem('channel-removed-state', sentState); - $('#removed_from_channel').modal('show'); - } - } - } - } updateTitle() { const channel = ChannelStore.getCurrent(); if (channel) { let currentSiteName = ''; - if (global.window.config.SiteName != null) { - currentSiteName = global.window.config.SiteName; + if (global.window.mm_config.SiteName != null) { + currentSiteName = global.window.mm_config.SiteName; } let currentChannelName = channel.display_name; @@ -276,9 +198,6 @@ export default class Sidebar extends React.Component { onScroll() { this.updateUnreadIndicators(); } - onResize() { - this.updateUnreadIndicators(); - } updateUnreadIndicators() { const container = $(ReactDOM.findDOMNode(this.refs.container)); @@ -471,11 +390,13 @@ export default class Sidebar extends React.Component { } let closeButton = null; - const removeTooltip = <Tooltip>{'Remove from list'}</Tooltip>; + const removeTooltip = ( + <Tooltip id='remove-dm-tooltip'>{'Remove from list'}</Tooltip> + ); if (handleClose && !badge) { closeButton = ( <OverlayTrigger - delayShow='1000' + delayShow={1000} placement='top' overlay={removeTooltip} > @@ -564,8 +485,12 @@ export default class Sidebar extends React.Component { showChannelModal = true; } - const createChannelTootlip = <Tooltip>{'Create new channel'}</Tooltip>; - const createGroupTootlip = <Tooltip>{'Create new group'}</Tooltip>; + const createChannelTootlip = ( + <Tooltip id='new-channel-tooltip' >{'Create new channel'}</Tooltip> + ); + const createGroupTootlip = ( + <Tooltip id='new-group-tooltip'>{'Create new group'}</Tooltip> + ); return ( <div> @@ -607,7 +532,7 @@ export default class Sidebar extends React.Component { <h4> {'Channels'} <OverlayTrigger - delayShow='500' + delayShow={500} placement='top' overlay={createChannelTootlip} > @@ -640,7 +565,7 @@ export default class Sidebar extends React.Component { <h4> {'Private Groups'} <OverlayTrigger - delayShow='500' + delayShow={500} placement='top' overlay={createGroupTootlip} > diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index c3709bc0a..de28a8374 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -3,6 +3,7 @@ var NavbarDropdown = require('./navbar_dropdown.jsx'); var UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); export default class SidebarHeader extends React.Component { constructor(props) { @@ -32,7 +33,7 @@ export default class SidebarHeader extends React.Component { profilePicture = ( <img className='user__picture' - src={'/api/v1/users/' + me.id + '/image?time=' + me.update_at} + src={'/api/v1/users/' + me.id + '/image?time=' + me.update_at + '&' + Utils.getSessionIndex()} /> ); } @@ -61,7 +62,7 @@ export default class SidebarHeader extends React.Component { } SidebarHeader.defaultProps = { - teamDisplayName: global.window.config.SiteName, + teamDisplayName: global.window.mm_config.SiteName, teamType: '' }; SidebarHeader.propTypes = { diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index ac101d631..fddc98c9d 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -84,7 +84,7 @@ export default class SidebarRightMenu extends React.Component { consoleLink = ( <li> <a - href='/admin_console' + href={'/admin_console?' + utils.getSessionIndex()} > <i className='glyphicon glyphicon-wrench'></i>System Console</a> </li> @@ -92,8 +92,8 @@ export default class SidebarRightMenu extends React.Component { } var siteName = ''; - if (global.window.config.SiteName != null) { - siteName = global.window.config.SiteName; + if (global.window.mm_config.SiteName != null) { + siteName = global.window.mm_config.SiteName; } var teamDisplayName = siteName; if (this.props.teamDisplayName) { diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx index 48cf2c73c..1858703ef 100644 --- a/web/react/components/signup_team.jsx +++ b/web/react/components/signup_team.jsx @@ -14,19 +14,19 @@ export default class TeamSignUp extends React.Component { var count = 0; - if (global.window.config.EnableSignUpWithEmail === 'true') { + if (global.window.mm_config.EnableSignUpWithEmail === 'true') { count = count + 1; } - if (global.window.config.EnableSignUpWithGitLab === 'true') { + if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { count = count + 1; } if (count > 1) { this.state = {page: 'choose'}; - } else if (global.window.config.EnableSignUpWithEmail === 'true') { + } else if (global.window.mm_config.EnableSignUpWithEmail === 'true') { this.state = {page: 'email'}; - } else if (global.window.config.EnableSignUpWithGitLab === 'true') { + } else if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { this.state = {page: 'gitlab'}; } } diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx index f74c29d27..d70ea5065 100644 --- a/web/react/components/signup_user_complete.jsx +++ b/web/react/components/signup_user_complete.jsx @@ -82,30 +82,29 @@ export default class SignupUserComplete extends React.Component { }); client.createUser(user, this.props.data, this.props.hash, - function createUserSuccess() { + () => { client.track('signup', 'signup_user_02_complete'); client.loginByEmail(this.props.teamName, user.email, user.password, - function emailLoginSuccess(data) { + () => { UserStore.setLastEmail(user.email); - UserStore.setCurrentUser(data); if (this.props.hash > 0) { BrowserStore.setGlobalItem(this.props.hash, JSON.stringify({wizard: 'finished'})); } window.location.href = '/' + this.props.teamName + '/channels/town-square'; - }.bind(this), - function emailLoginFailure(err) { + }, + (err) => { if (err.message === 'Login failed because email address has not been verified') { window.location.href = '/verify_email?email=' + encodeURIComponent(user.email) + '&teamname=' + encodeURIComponent(this.props.teamName); } else { this.setState({serverError: err.message}); } - }.bind(this) + } ); - }.bind(this), - function createUserFailure(err) { + }, + (err) => { this.setState({serverError: err.message}); - }.bind(this) + } ); } render() { @@ -149,7 +148,7 @@ export default class SignupUserComplete extends React.Component { // set up the email entry and hide it if an email was provided var yourEmailIs = ''; if (this.state.user.email) { - yourEmailIs = <span>Your email address is <strong>{this.state.user.email}</strong>. You'll use this address to sign in to {global.window.config.SiteName}.</span>; + yourEmailIs = <span>Your email address is <strong>{this.state.user.email}</strong>. You'll use this address to sign in to {global.window.mm_config.SiteName}.</span>; } var emailContainerStyle = 'margin--extra'; @@ -177,7 +176,7 @@ export default class SignupUserComplete extends React.Component { ); var signupMessage = []; - if (global.window.config.EnableSignUpWithGitLab === 'true') { + if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { signupMessage.push( <a className='btn btn-custom-login gitlab' @@ -190,7 +189,7 @@ export default class SignupUserComplete extends React.Component { } var emailSignup; - if (global.window.config.EnableSignUpWithEmail === 'true') { + if (global.window.mm_config.EnableSignUpWithEmail === 'true') { emailSignup = ( <div> <div className='inner__content'> @@ -259,7 +258,7 @@ export default class SignupUserComplete extends React.Component { /> <h5 className='margin--less'>Welcome to:</h5> <h2 className='signup-team__name'>{this.props.teamDisplayName}</h2> - <h2 className='signup-team__subdomain'>on {global.window.config.SiteName}</h2> + <h2 className='signup-team__subdomain'>on {global.window.mm_config.SiteName}</h2> <h4 className='color--light'>Let's create your account</h4> {signupMessage} {emailSignup} diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index b55373dba..5c5995020 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -19,6 +19,7 @@ export default class TeamSettingsModal extends React.Component { componentDidMount() { $('body').on('click', '.modal-back', function handleBackClick() { $(this).closest('.modal-dialog').removeClass('display--content'); + $(this).closest('.modal-dialog').find('.settings-table .nav li.active').removeClass('active'); }); $('body').on('click', '.modal-header .close', () => { setTimeout(() => { diff --git a/web/react/components/team_signup_choose_auth.jsx b/web/react/components/team_signup_choose_auth.jsx index fa898f63c..0254c8b4e 100644 --- a/web/react/components/team_signup_choose_auth.jsx +++ b/web/react/components/team_signup_choose_auth.jsx @@ -8,7 +8,7 @@ export default class ChooseAuthPage extends React.Component { } render() { var buttons = []; - if (global.window.config.EnableSignUpWithGitLab === 'true') { + if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { buttons.push( <a className='btn btn-custom-login gitlab btn-full' @@ -26,7 +26,7 @@ export default class ChooseAuthPage extends React.Component { ); } - if (global.window.config.EnableSignUpWithEmail === 'true') { + if (global.window.mm_config.EnableSignUpWithEmail === 'true') { buttons.push( <a className='btn btn-custom-login email btn-full' diff --git a/web/react/components/team_signup_password_page.jsx b/web/react/components/team_signup_password_page.jsx index daa898b53..67fd686bc 100644 --- a/web/react/components/team_signup_password_page.jsx +++ b/web/react/components/team_signup_password_page.jsx @@ -36,15 +36,14 @@ export default class TeamSignupPasswordPage extends React.Component { delete teamSignup.wizard; Client.createTeamFromSignup(teamSignup, - function success() { + () => { Client.track('signup', 'signup_team_08_complete'); var props = this.props; Client.loginByEmail(teamSignup.team.name, teamSignup.team.email, teamSignup.user.password, - function loginSuccess(data) { + () => { UserStore.setLastEmail(teamSignup.team.email); - UserStore.setCurrentUser(data); if (this.props.hash > 0) { BrowserStore.setGlobalItem(this.props.hash, JSON.stringify({wizard: 'finished'})); } @@ -54,21 +53,21 @@ export default class TeamSignupPasswordPage extends React.Component { props.updateParent(props.state, true); window.location.href = '/' + teamSignup.team.name + '/channels/town-square'; - }.bind(this), - function loginFail(err) { + }, + (err) => { if (err.message === 'Login failed because email address has not been verified') { window.location.href = '/verify_email?email=' + encodeURIComponent(teamSignup.team.email) + '&teamname=' + encodeURIComponent(teamSignup.team.name); } else { this.setState({serverError: err.message}); $('#finish-button').button('reset'); } - }.bind(this) + } ); - }.bind(this), - function error(err) { + }, + (err) => { this.setState({serverError: err.message}); $('#finish-button').button('reset'); - }.bind(this) + } ); } render() { @@ -129,7 +128,7 @@ export default class TeamSignupPasswordPage extends React.Component { Finish </button> </div> - <p>By proceeding to create your account and use {global.window.config.SiteName}, you agree to our <a href='/static/help/terms.html'>Terms of Service</a> and <a href='/static/help/privacy.html'>Privacy Policy</a>. If you do not agree, you cannot use {global.window.config.SiteName}.</p> + <p>By proceeding to create your account and use {global.window.mm_config.SiteName}, you agree to our <a href='/static/help/terms.html'>Terms of Service</a> and <a href='/static/help/privacy.html'>Privacy Policy</a>. If you do not agree, you cannot use {global.window.mm_config.SiteName}.</p> <div className='margin--extra'> <a href='#' diff --git a/web/react/components/team_signup_send_invites_page.jsx b/web/react/components/team_signup_send_invites_page.jsx index e7bc0272d..7b4db8fae 100644 --- a/web/react/components/team_signup_send_invites_page.jsx +++ b/web/react/components/team_signup_send_invites_page.jsx @@ -13,13 +13,8 @@ export default class TeamSignupSendInvitesPage extends React.Component { this.submitSkip = this.submitSkip.bind(this); this.keySubmit = this.keySubmit.bind(this); this.state = { - emailEnabled: global.window.config.SendEmailNotifications === 'true' + emailEnabled: global.window.mm_config.SendEmailNotifications === 'true' }; - - if (!this.state.emailEnabled) { - this.props.state.wizard = 'username'; - this.props.updateParent(this.props.state); - } } submitBack(e) { e.preventDefault(); diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx index 67e4c9dd7..02d5cab8e 100644 --- a/web/react/components/team_signup_url_page.jsx +++ b/web/react/components/team_signup_url_page.jsx @@ -40,10 +40,12 @@ export default class TeamSignupUrlPage extends React.Component { return; } - for (let index = 0; index < Constants.RESERVED_TEAM_NAMES.length; index++) { - if (cleanedName.indexOf(Constants.RESERVED_TEAM_NAMES[index]) === 0) { - this.setState({nameError: 'URL is taken or contains a reserved word'}); - return; + if (global.window.mm_config.RestrictTeamNames === 'true') { + for (let index = 0; index < Constants.RESERVED_TEAM_NAMES.length; index++) { + if (cleanedName.indexOf(Constants.RESERVED_TEAM_NAMES[index]) === 0) { + this.setState({nameError: 'URL is taken or contains a reserved word'}); + return; + } } } @@ -52,7 +54,11 @@ export default class TeamSignupUrlPage extends React.Component { if (data) { this.setState({nameError: 'This URL is unavailable. Please try another.'}); } else { - this.props.state.wizard = 'send_invites'; + if (global.window.mm_config.SendEmailNotifications === 'true') { + this.props.state.wizard = 'send_invites'; + } else { + this.props.state.wizard = 'username'; + } this.props.state.team.type = 'O'; this.props.state.team.name = name; diff --git a/web/react/components/team_signup_username_page.jsx b/web/react/components/team_signup_username_page.jsx index fa8a031a0..d8d0dbf2c 100644 --- a/web/react/components/team_signup_username_page.jsx +++ b/web/react/components/team_signup_username_page.jsx @@ -15,7 +15,12 @@ export default class TeamSignupUsernamePage extends React.Component { } submitBack(e) { e.preventDefault(); - this.props.state.wizard = 'send_invites'; + if (global.window.mm_config.SendEmailNotifications === 'true') { + this.props.state.wizard = 'send_invites'; + } else { + this.props.state.wizard = 'team_url'; + } + this.props.updateParent(this.props.state); } submitNext(e) { diff --git a/web/react/components/team_signup_welcome_page.jsx b/web/react/components/team_signup_welcome_page.jsx index 1e9d8df0a..9448413ce 100644 --- a/web/react/components/team_signup_welcome_page.jsx +++ b/web/react/components/team_signup_welcome_page.jsx @@ -104,21 +104,19 @@ export default class TeamSignupWelcomePage extends React.Component { return ( <div> - <p> - <img - className='signup-team-logo' - src='/static/images/logo.png' - /> - <h3 className='sub-heading'>Welcome to:</h3> - <h1 className='margin--top-none'>{global.window.config.SiteName}</h1> - </p> + <img + className='signup-team-logo' + src='/static/images/logo.png' + /> + <h3 className='sub-heading'>Welcome to:</h3> + <h1 className='margin--top-none'>{global.window.mm_config.SiteName}</h1> <p className='margin--less'>Let's set up your new team</p> - <p> + <div> Please confirm your email address:<br /> <div className='inner__content'> <div className='block--gray'>{this.props.state.team.email}</div> </div> - </p> + </div> <p className='margin--extra color--light'> Your account will administer the new team site. <br /> You can add other administrators later. diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx index 715161b4f..c4402ae23 100644 --- a/web/react/components/user_profile.jsx +++ b/web/react/components/user_profile.jsx @@ -65,22 +65,33 @@ export default class UserProfile extends React.Component { var dataContent = []; dataContent.push( - <img className='user-popover__image' - src={'/api/v1/users/' + this.state.profile.id + '/image?time=' + this.state.profile.update_at} + <img + className='user-popover__image' + src={'/api/v1/users/' + this.state.profile.id + '/image?time=' + this.state.profile.update_at + '&' + Utils.getSessionIndex()} height='128' width='128' + key='user-popover-image' /> ); - if (!global.window.config.ShowEmailAddress === 'true') { - dataContent.push(<div className='text-nowrap'>{'Email not shared'}</div>); + + if (!global.window.mm_config.ShowEmailAddress === 'true') { + dataContent.push( + <div + className='text-nowrap' + key='user-popover-no-email' + > + {'Email not shared'} + </div> + ); } else { dataContent.push( <div data-toggle='tooltip' - title="' + this.state.profile.email + '" + title={this.state.profile.email} + key='user-popover-email' > <a - href="mailto:' + this.state.profile.email + '" + href={'mailto:' + this.state.profile.email} className='text-nowrap text-lowercase user-popover__email' > {this.state.profile.email} @@ -93,15 +104,22 @@ export default class UserProfile extends React.Component { <OverlayTrigger trigger='click' placement='right' - rootClose='true' - overlay={<Popover title={this.state.profile.username}>{dataContent}</Popover>} - > - <div - className='user-popover' - id={'profile_' + this.uniqueId} + rootClose={true} + overlay={ + <Popover + title={this.state.profile.username} + id='user-profile-popover' + > + {dataContent} + </Popover> + } > - {name} - </div> + <div + className='user-popover' + id={'profile_' + this.uniqueId} + > + {name} + </div> </OverlayTrigger> ); } diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx index f5a2774a0..6b8c09718 100644 --- a/web/react/components/user_settings/manage_incoming_hooks.jsx +++ b/web/react/components/user_settings/manage_incoming_hooks.jsx @@ -96,7 +96,14 @@ export default class ManageIncomingHooks extends React.Component { const options = []; channels.forEach((channel) => { if (channel.type !== Constants.DM_CHANNEL) { - options.push(<option value={channel.id}>{channel.name}</option>); + options.push( + <option + key={'incoming-hook' + channel.id} + value={channel.id} + > + {channel.display_name} + </option> + ); } }); @@ -108,26 +115,30 @@ export default class ManageIncomingHooks extends React.Component { const hooks = []; this.state.hooks.forEach((hook) => { const c = ChannelStore.get(hook.channel_id); - hooks.push( - <div className='font--small'> - <div className='padding-top x2 divider-light'></div> - <div className='padding-top x2'> - <strong>{'URL: '}</strong><span className='word-break--all'>{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}</span> - </div> - <div className='padding-top'> - <strong>{'Channel: '}</strong>{c.name} - </div> - <div className='padding-top'> + if (c) { + hooks.push( + <div + key={hook.id} + className='webhook__item' + > + <div className='padding-top x2 webhook__url'> + <strong>{'URL: '}</strong> + <span className='word-break--all'>{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}</span> + </div> + <div className='padding-top'> + <strong>{'Channel: '}</strong>{c.display_name} + </div> <a - className={'text-danger'} + className={'webhook__remove'} href='#' onClick={this.removeHook.bind(this, hook.id)} > - {'Remove'} + <span aria-hidden='true'>{'×'}</span> </a> + <div className='padding-top x2 divider-light'></div> </div> - </div> - ); + ); + } }); let displayHooks; @@ -136,35 +147,38 @@ export default class ManageIncomingHooks extends React.Component { } else if (hooks.length > 0) { displayHooks = hooks; } else { - displayHooks = <label>{': None'}</label>; + displayHooks = <div className='padding-top x2'>{'None'}</div>; } const existingHooks = ( - <div className='padding-top x2'> + <div className='webhooks__container'> <label className='control-label padding-top x2'>{'Existing incoming webhooks'}</label> - {displayHooks} + <div className='padding-top divider-light'></div> + <div className='webhooks__list'> + {displayHooks} + </div> </div> ); return ( <div key='addIncomingHook'> {'Create webhook URLs for use in external integrations. Please see '}<a href='http://mattermost.org/webhooks'>{'http://mattermost.org/webhooks'}</a> {' to learn more.'} - <br/> - <br/> - <label className='control-label'>{'Add a new incoming webhook'}</label> - <div className='padding-top'> - <select - ref='channelName' - className='form-control' - value={this.state.channelId} - onChange={this.updateChannelId} - > - {options} - </select> - {serverError} - <div className='padding-top'> + <label className='control-label padding-top x2'>{'Add a new incoming webhook'}</label> + <div className='row padding-top'> + <div className='col-sm-10 padding-bottom'> + <select + ref='channelName' + className='form-control' + value={this.state.channelId} + onChange={this.updateChannelId} + > + {options} + </select> + {serverError} + </div> + <div className='col-sm-2 col-xs-4 no-padding--left padding-bottom'> <a - className={'btn btn-sm btn-primary' + disableButton} + className={'btn form-control no-padding btn-sm btn-primary' + disableButton} href='#' onClick={this.addNewHook} > diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx new file mode 100644 index 000000000..6e9b2205d --- /dev/null +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -0,0 +1,297 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Constants = require('../../utils/constants.jsx'); +var ChannelStore = require('../../stores/channel_store.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); + + +export default class ManageOutgoingHooks extends React.Component { + constructor() { + super(); + + this.getHooks = this.getHooks.bind(this); + this.addNewHook = this.addNewHook.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + this.updateTriggerWords = this.updateTriggerWords.bind(this); + this.updateCallbackURLs = this.updateCallbackURLs.bind(this); + + this.state = {hooks: [], channelId: '', triggerWords: '', callbackURLs: '', getHooksComplete: false}; + } + componentDidMount() { + this.getHooks(); + } + addNewHook(e) { + e.preventDefault(); + + if ((this.state.channelId === '' && this.state.triggerWords === '') || + this.state.callbackURLs === '') { + return; + } + + const hook = {}; + hook.channel_id = this.state.channelId; + if (this.state.triggerWords.length !== 0) { + hook.trigger_words = this.state.triggerWords.trim().split(','); + } + hook.callback_urls = this.state.callbackURLs.split('\n'); + + Client.addOutgoingHook( + hook, + (data) => { + let hooks = Object.assign([], this.state.hooks); + if (!hooks) { + hooks = []; + } + hooks.push(data); + this.setState({hooks, serverError: null, channelId: '', triggerWords: '', callbackURLs: ''}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + removeHook(id) { + const data = {}; + data.id = id; + + Client.deleteOutgoingHook( + data, + () => { + const hooks = this.state.hooks; + let index = -1; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + hooks.splice(index, 1); + } + + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + regenToken(id) { + const regenData = {}; + regenData.id = id; + + Client.regenOutgoingHookToken( + regenData, + (data) => { + const hooks = Object.assign([], this.state.hooks); + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + hooks[i] = data; + break; + } + } + + this.setState({hooks, serverError: null}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + getHooks() { + Client.listOutgoingHooks( + (data) => { + if (data) { + this.setState({hooks: data, getHooksComplete: true, serverError: null}); + } + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + updateChannelId(e) { + this.setState({channelId: e.target.value}); + } + updateTriggerWords(e) { + this.setState({triggerWords: e.target.value}); + } + updateCallbackURLs(e) { + this.setState({callbackURLs: e.target.value}); + } + render() { + let serverError; + if (this.state.serverError) { + serverError = <label className='has-error'>{this.state.serverError}</label>; + } + + const channels = ChannelStore.getAll(); + const options = []; + options.push( + <option + key='select-channel' + value='' + > + {'--- Select a channel ---'} + </option> + ); + + channels.forEach((channel) => { + if (channel.type === Constants.OPEN_CHANNEL) { + options.push( + <option + key={'outgoing-hook' + channel.id} + value={channel.id} + > + {channel.display_name} + </option> + ); + } + }); + + const hooks = []; + this.state.hooks.forEach((hook) => { + const c = ChannelStore.get(hook.channel_id); + + if (!c && hook.channel_id && hook.channel_id.length !== 0) { + return; + } + + let channelDiv; + if (c) { + channelDiv = ( + <div className='padding-top'> + <strong>{'Channel: '}</strong>{c.display_name} + </div> + ); + } + + let triggerDiv; + if (hook.trigger_words && hook.trigger_words.length !== 0) { + triggerDiv = ( + <div className='padding-top'> + <strong>{'Trigger Words: '}</strong>{hook.trigger_words.join(', ')} + </div> + ); + } + + hooks.push( + <div + key={hook.id} + className='webhook__item' + > + <div className='padding-top x2'> + <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span> + </div> + {channelDiv} + {triggerDiv} + <div className='padding-top'> + <strong>{'Token: '}</strong>{hook.token} + </div> + <div className='padding-top'> + <a + className='text-danger' + href='#' + onClick={this.regenToken.bind(this, hook.id)} + > + {'Regen Token'} + </a> + <a + className='webhook__remove' + href='#' + onClick={this.removeHook.bind(this, hook.id)} + > + <span aria-hidden='true'>{'×'}</span> + </a> + </div> + <div className='padding-top x2 divider-light'></div> + </div> + ); + }); + + let displayHooks; + if (!this.state.getHooksComplete) { + displayHooks = <LoadingScreen/>; + } else if (hooks.length > 0) { + displayHooks = hooks; + } else { + displayHooks = <div className='padding-top x2'>{'None'}</div>; + } + + const existingHooks = ( + <div className='webhooks__container'> + <label className='control-label padding-top x2'>{'Existing outgoing webhooks'}</label> + <div className='padding-top divider-light'></div> + <div className='webhooks__list'> + {displayHooks} + </div> + </div> + ); + + const disableButton = (this.state.channelId === '' && this.state.triggerWords === '') || this.state.callbackURLs === ''; + + return ( + <div key='addOutgoingHook'> + <label className='control-label'>{'Add a new outgoing webhook'}</label> + <div className='padding-top'> + <div> + <label className='control-label'>{'Channel'}</label> + <div className='padding-top'> + <select + ref='channelName' + className='form-control' + value={this.state.channelId} + onChange={this.updateChannelId} + > + {options} + </select> + </div> + <div className='padding-top'>{'Only public channels can be used'}</div> + </div> + <div className='padding-top x2'> + <label className='control-label'>{'Trigger Words:'}</label> + <div className='padding-top'> + <input + ref='triggerWords' + className='form-control' + value={this.state.triggerWords} + onChange={this.updateTriggerWords} + placeholder='Optional if channel selected' + /> + </div> + <div className='padding-top'>{'Comma separated words to trigger on'}</div> + </div> + <div className='padding-top x2'> + <label className='control-label'>{'Callback URLs:'}</label> + <div className='padding-top'> + <textarea + ref='callbackURLs' + className='form-control no-resize' + value={this.state.callbackURLs} + resize={false} + rows={3} + onChange={this.updateCallbackURLs} + /> + </div> + <div className='padding-top'>{'New line separated URLs that will receive the HTTP POST event'}</div> + {serverError} + </div> + <div className='padding-top padding-bottom'> + <a + className={'btn btn-sm btn-primary'} + href='#' + disabled={disableButton} + onClick={this.addNewHook} + > + {'Add'} + </a> + </div> + </div> + {existingHooks} + </div> + ); + } +} diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index 7f363e92e..8c62a189d 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -152,9 +152,8 @@ export default class UserSettingsAppearance extends React.Component { <input type='radio' checked={!displayCustom} onChange={this.updateType.bind(this, 'premade')} - > - {'Theme Colors'} - </input> + /> + {'Theme Colors'} </label> <br/> </div> @@ -164,9 +163,8 @@ export default class UserSettingsAppearance extends React.Component { <input type='radio' checked={displayCustom} onChange={this.updateType.bind(this, 'custom')} - > - {'Custom Theme'} - </input> + /> + {'Custom Theme'} </label> <br/> </div> diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx index ec209c218..22a62273c 100644 --- a/web/react/components/user_settings/user_settings_display.jsx +++ b/web/react/components/user_settings/user_settings_display.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import { savePreferences } from '../../utils/client.jsx'; +import {savePreferences} from '../../utils/client.jsx'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import Constants from '../../utils/constants.jsx'; @@ -38,7 +38,7 @@ export default class UserSettingsDisplay extends React.Component { ); } handleClockRadio(militaryTime) { - this.setState({militaryTime: militaryTime}); + this.setState({militaryTime}); } updateSection(section) { this.setState(getDisplayStateFromStores()); @@ -57,7 +57,7 @@ export default class UserSettingsDisplay extends React.Component { const serverError = this.state.serverError || null; let clockSection; if (this.props.activeSection === 'clock') { - let clockFormat = [false, false]; + const clockFormat = [false, false]; if (this.state.militaryTime === 'true') { clockFormat[1] = true; } else { @@ -77,9 +77,8 @@ export default class UserSettingsDisplay extends React.Component { type='radio' checked={clockFormat[0]} onChange={this.handleClockRadio.bind(this, 'false')} - > - 12-hour clock (example: 4:00 PM) - </input> + /> + {'12-hour clock (example: 4:00 PM)'} </label> <br/> </div> @@ -89,9 +88,8 @@ export default class UserSettingsDisplay extends React.Component { type='radio' checked={clockFormat[1]} onChange={this.handleClockRadio.bind(this, 'true')} - > - 24-hour clock (example: 16:00) - </input> + /> + {'24-hour clock (example: 16:00)'} </label> <br/> </div> @@ -99,7 +97,6 @@ export default class UserSettingsDisplay extends React.Component { </div> ]; - clockSection = ( <SettingItemMax title='Clock Display' @@ -138,13 +135,13 @@ export default class UserSettingsDisplay extends React.Component { className='close' data-dismiss='modal' aria-label='Close' - > + > <span aria-hidden='true'>{'×'}</span> </button> <h4 className='modal-title' ref='title' - > + > <i className='modal-back'></i> {'Display Settings'} </h4> diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 9c03f77a6..70e559c30 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -122,7 +122,7 @@ export default class UserSettingsGeneralTab extends React.Component { () => { this.updateSection(''); AsyncClient.getMe(); - const verificationEnabled = global.window.config.SendEmailNotifications === 'true' && global.window.config.RequireEmailVerification === 'true' && emailUpdated; + const verificationEnabled = global.window.mm_config.SendEmailNotifications === 'true' && global.window.mm_config.RequireEmailVerification === 'true' && emailUpdated; if (verificationEnabled) { ErrorStore.storeLastError({message: 'Check your email at ' + user.email + ' to verify the address.'}); @@ -451,8 +451,8 @@ export default class UserSettingsGeneralTab extends React.Component { } var emailSection; if (this.props.activeSection === 'email') { - const emailEnabled = global.window.config.SendEmailNotifications === 'true'; - const emailVerificationEnabled = global.window.config.RequireEmailVerification === 'true'; + const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true'; + const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true'; let helpText = 'Email is used for notifications, and requires verification if changed.'; if (!emailEnabled) { @@ -542,7 +542,7 @@ export default class UserSettingsGeneralTab extends React.Component { <SettingPicture title='Profile Picture' submit={this.submitPicture} - src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update} + src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update + '&' + utils.getSessionIndex()} server_error={serverError} client_error={clientError} updateSection={function clearSection(e) { diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 3be062ad3..4b1e5e532 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -4,6 +4,7 @@ var SettingItemMin = require('../setting_item_min.jsx'); var SettingItemMax = require('../setting_item_max.jsx'); var ManageIncomingHooks = require('./manage_incoming_hooks.jsx'); +var ManageOutgoingHooks = require('./manage_outgoing_hooks.jsx'); export default class UserSettingsIntegrationsTab extends React.Component { constructor(props) { @@ -19,6 +20,8 @@ export default class UserSettingsIntegrationsTab extends React.Component { } handleClose() { this.updateSection(''); + $('.ps-container.modal-body').scrollTop(0); + $('.ps-container.modal-body').perfectScrollbar('update'); } componentDidMount() { $('#user_settings').on('hidden.bs.modal', this.handleClose); @@ -28,35 +31,65 @@ export default class UserSettingsIntegrationsTab extends React.Component { } render() { let incomingHooksSection; + let outgoingHooksSection; var inputs = []; - if (this.props.activeSection === 'incoming-hooks') { - inputs.push( - <ManageIncomingHooks /> - ); + if (global.window.mm_config.EnableIncomingWebhooks === 'true') { + if (this.props.activeSection === 'incoming-hooks') { + inputs.push( + <ManageIncomingHooks key='incoming-hook-ui' /> + ); - incomingHooksSection = ( - <SettingItemMax - title='Incoming Webhooks' - width = 'full' - inputs={inputs} - updateSection={function clearSection(e) { - this.updateSection(''); - e.preventDefault(); - }.bind(this)} - /> - ); - } else { - incomingHooksSection = ( - <SettingItemMin - title='Incoming Webhooks' - width = 'full' - describe='Manage your incoming webhooks (Developer feature)' - updateSection={function updateNameSection() { - this.updateSection('incoming-hooks'); - }.bind(this)} - /> - ); + incomingHooksSection = ( + <SettingItemMax + title='Incoming Webhooks' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + incomingHooksSection = ( + <SettingItemMin + title='Incoming Webhooks' + describe='Manage your incoming webhooks (Developer feature)' + updateSection={() => { + this.updateSection('incoming-hooks'); + }} + /> + ); + } + } + + if (global.window.mm_config.EnableOutgoingWebhooks === 'true') { + if (this.props.activeSection === 'outgoing-hooks') { + inputs.push( + <ManageOutgoingHooks key='outgoing-hook-ui' /> + ); + + outgoingHooksSection = ( + <SettingItemMax + title='Outgoing Webhooks' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + outgoingHooksSection = ( + <SettingItemMin + title='Outgoing Webhooks' + describe='Manage your outgoing webhooks' + updateSection={() => { + this.updateSection('outgoing-hooks'); + }} + /> + ); + } } return ( @@ -82,6 +115,8 @@ export default class UserSettingsIntegrationsTab extends React.Component { <h3 className='tab-header'>{'Integration Settings'}</h3> <div className='divider-dark first'/> {incomingHooksSection} + <div className='divider-light'/> + {outgoingHooksSection} <div className='divider-dark'/> </div> </div> diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index 692fb26ee..5449ae91e 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -35,10 +35,11 @@ export default class UserSettingsModal extends React.Component { tabs.push({name: 'security', uiName: 'Security', icon: 'glyphicon glyphicon-lock'}); tabs.push({name: 'notifications', uiName: 'Notifications', icon: 'glyphicon glyphicon-exclamation-sign'}); tabs.push({name: 'appearance', uiName: 'Appearance', icon: 'glyphicon glyphicon-wrench'}); - if (global.window.config.EnableOAuthServiceProvider === 'true') { + if (global.window.mm_config.EnableOAuthServiceProvider === 'true') { tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'}); } - if (global.window.config.EnableIncomingWebhooks === 'true') { + + if (global.window.mm_config.EnableIncomingWebhooks === 'true' || global.window.mm_config.EnableOutgoingWebhooks === 'true') { tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); } tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'}); diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx index 4dbb9b96f..61d49acb2 100644 --- a/web/react/components/user_settings/user_settings_notifications.jsx +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -228,9 +228,8 @@ export default class NotificationsTab extends React.Component { <input type='radio' checked={notifyActive[0]} onChange={this.handleNotifyRadio.bind(this, 'all')} - > - For all activity - </input> + /> + {'For all activity'} </label> <br/> </div> @@ -240,9 +239,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={notifyActive[1]} onChange={this.handleNotifyRadio.bind(this, 'mention')} - > - Only for mentions and direct messages - </input> + /> + {'Only for mentions and direct messages'} </label> <br/> </div> @@ -252,9 +250,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={notifyActive[2]} onChange={this.handleNotifyRadio.bind(this, 'none')} - > - Never - </input> + /> + {'Never'} </label> </div> </div> @@ -320,9 +317,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={soundActive[0]} onChange={this.handleSoundRadio.bind(this, 'true')} - > - On - </input> + /> + {'On'} </label> <br/> </div> @@ -332,9 +328,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={soundActive[1]} onChange={this.handleSoundRadio.bind(this, 'false')} - > - Off - </input> + /> + {'Off'} </label> <br/> </div> @@ -402,9 +397,8 @@ export default class NotificationsTab extends React.Component { type='radio' checked={emailActive[0]} onChange={this.handleEmailRadio.bind(this, 'true')} - > - On - </input> + /> + {'On'} </label> <br/> </div> @@ -414,13 +408,12 @@ export default class NotificationsTab extends React.Component { type='radio' checked={emailActive[1]} onChange={this.handleEmailRadio.bind(this, 'false')} - > - Off - </input> + /> + {'Off'} </label> <br/> </div> - <div><br/>{'Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from ' + global.window.config.SiteName + ' for more than 5 minutes.'}</div> + <div><br/>{'Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from ' + global.window.mm_config.SiteName + ' for more than 5 minutes.'}</div> </div> ); @@ -482,9 +475,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.firstNameKey} onChange={handleUpdateFirstNameKey} - > - {'Your case sensitive first name "' + user.first_name + '"'} - </input> + /> + {'Your case sensitive first name "' + user.first_name + '"'} </label> </div> </div> @@ -502,9 +494,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.usernameKey} onChange={handleUpdateUsernameKey} - > - {'Your non-case sensitive username "' + user.username + '"'} - </input> + /> + {'Your non-case sensitive username "' + user.username + '"'} </label> </div> </div> @@ -521,9 +512,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.mentionKey} onChange={handleUpdateMentionKey} - > - {'Your username mentioned "@' + user.username + '"'} - </input> + /> + {'Your username mentioned "@' + user.username + '"'} </label> </div> </div> @@ -540,9 +530,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.allKey} onChange={handleUpdateAllKey} - > - {'Team-wide mentions "@all"'} - </input> + /> + {'Team-wide mentions "@all"'} </label> </div> </div> @@ -559,9 +548,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.channelKey} onChange={handleUpdateChannelKey} - > - {'Channel-wide mentions "@channel"'} - </input> + /> + {'Channel-wide mentions "@channel"'} </label> </div> </div> @@ -576,9 +564,8 @@ export default class NotificationsTab extends React.Component { type='checkbox' checked={this.state.customKeysChecked} onChange={this.updateCustomMentionKeys} - > - {'Other non-case sensitive words, separated by commas:'} - </input> + /> + {'Other non-case sensitive words, separated by commas:'} </label> </div> <input diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index f75693470..92d7cd835 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -6,6 +6,7 @@ const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const ViewImagePopoverBar = require('./view_image_popover_bar.jsx'); const Modal = ReactBootstrap.Modal; +const KeyCodes = Constants.KeyCodes; export default class ViewImageModal extends React.Component { constructor(props) { @@ -37,7 +38,10 @@ export default class ViewImageModal extends React.Component { progress: progress, images: {}, fileSizes: {}, - showFooter: false + fileMimes: {}, + showFooter: false, + isPlaying: {}, + isLoading: {} }; } handleNext(e) { @@ -63,11 +67,11 @@ export default class ViewImageModal extends React.Component { this.loadImage(id); } handleKeyPress(e) { - if (!e) { + if (!e || !this.props.show) { return; - } else if (e.keyCode === 39) { + } else if (e.keyCode === KeyCodes.RIGHT) { this.handleNext(); - } else if (e.keyCode === 37) { + } else if (e.keyCode === KeyCodes.LEFT) { this.handlePrev(); } } @@ -121,6 +125,36 @@ 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); @@ -153,13 +187,17 @@ export default class ViewImageModal extends React.Component { 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'; + return fileInfo.path + '_preview.jpg' + '?' + Utils.getSessionIndex(); } // only images have proper previews, so just use a placeholder icon for non-images @@ -188,12 +226,62 @@ export default class ViewImageModal extends React.Component { var fileType = Utils.getFileType(fileInfo.ext); 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); + } + }, + () => {} + ); + } + + 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 = ''; + } + // 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' @@ -218,7 +306,7 @@ export default class ViewImageModal extends React.Component { width={width} height={height} > - <source src={Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename} /> + <source src={Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename + '?' + Utils.getSessionIndex()} /> </video> ); } else { diff --git a/web/react/components/view_image_popover_bar.jsx b/web/react/components/view_image_popover_bar.jsx index 5b3ee540c..1287f4fba 100644 --- a/web/react/components/view_image_popover_bar.jsx +++ b/web/react/components/view_image_popover_bar.jsx @@ -7,7 +7,7 @@ export default class ViewImagePopoverBar extends React.Component { } render() { var publicLink = ''; - if (global.window.config.EnablePublicLink === 'true') { + if (global.window.mm_config.EnablePublicLink === 'true') { publicLink = ( <div> <a diff --git a/web/react/pages/admin_console.jsx b/web/react/pages/admin_console.jsx index c89cb4edc..ea9ae06f4 100644 --- a/web/react/pages/admin_console.jsx +++ b/web/react/pages/admin_console.jsx @@ -5,9 +5,12 @@ var ErrorBar = require('../components/error_bar.jsx'); var SelectTeamModal = require('../components/admin_console/select_team_modal.jsx'); var AdminController = require('../components/admin_console/admin_controller.jsx'); -export function setupAdminConsolePage() { +export function setupAdminConsolePage(props) { ReactDOM.render( - <AdminController />, + <AdminController + tab={props.ActiveTab} + teamId={props.TeamId} + />, document.getElementById('admin_controller') ); diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 20ed1bf0a..03e049db0 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -35,26 +35,18 @@ var RemovedFromChannelModal = require('../components/removed_from_channel_modal. var FileUploadOverlay = require('../components/file_upload_overlay.jsx'); var RegisterAppModal = require('../components/register_app_modal.jsx'); var ImportThemeModal = require('../components/user_settings/import_theme_modal.jsx'); -var TeamStore = require('../stores/team_store.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; function setupChannelPage(props) { - TeamStore.setCurrentId(props.TeamId); - AppDispatcher.handleViewAction({ type: ActionTypes.CLICK_CHANNEL, name: props.ChannelName, id: props.ChannelId }); - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_TEAM, - id: props.TeamId - }); - AsyncClient.getAllPreferences(); // ChannelLoader must be rendered first @@ -237,7 +229,7 @@ function setupChannelPage(props) { document.getElementById('register_app_modal') ); - if (global.window.config.SendEmailNotifications === 'false') { + if (global.window.mm_config.SendEmailNotifications === 'false') { ErrorStore.storeLastError({message: 'Preview Mode: Email notifications have not been configured'}); ErrorStore.emitChange(); } diff --git a/web/react/pages/home.jsx b/web/react/pages/home.jsx index 5f0fa9d96..a59f2afd0 100644 --- a/web/react/pages/home.jsx +++ b/web/react/pages/home.jsx @@ -2,14 +2,15 @@ // See License.txt for license information. var ChannelStore = require('../stores/channel_store.jsx'); +var TeamStore = require('../stores/team_store.jsx'); var Constants = require('../utils/constants.jsx'); -function setupHomePage(props) { +function setupHomePage() { var last = ChannelStore.getLastVisitedName(); if (last == null || last.length === 0) { - window.location = props.TeamURL + '/channels/' + Constants.DEFAULT_CHANNEL; + window.location = TeamStore.getCurrentTeamUrl() + '/channels/' + Constants.DEFAULT_CHANNEL; } else { - window.location = props.TeamURL + '/channels/' + last; + window.location = TeamStore.getCurrentTeamUrl() + '/channels/' + last; } } diff --git a/web/react/stores/browser_store.jsx b/web/react/stores/browser_store.jsx index c2e7df58e..75fb8aa3c 100644 --- a/web/react/stores/browser_store.jsx +++ b/web/react/stores/browser_store.jsx @@ -1,12 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore; function getPrefix() { - if (!UserStore) { - UserStore = require('./user_store.jsx'); //eslint-disable-line global-require + if (global.window.mm_user) { + return global.window.mm_user.id + '_'; } - return UserStore.getCurrentId() + '_'; + + return 'unknown_'; } class BrowserStoreClass { @@ -17,35 +17,55 @@ class BrowserStoreClass { this.setGlobalItem = this.setGlobalItem.bind(this); this.getGlobalItem = this.getGlobalItem.bind(this); this.removeGlobalItem = this.removeGlobalItem.bind(this); - this.clear = this.clear.bind(this); this.actionOnItemsWithPrefix = this.actionOnItemsWithPrefix.bind(this); + this.actionOnGlobalItemsWithPrefix = this.actionOnGlobalItemsWithPrefix.bind(this); this.isLocalStorageSupported = this.isLocalStorageSupported.bind(this); + this.getLastServerVersion = this.getLastServerVersion.bind(this); + this.setLastServerVersion = this.setLastServerVersion.bind(this); + this.clear = this.clear.bind(this); + this.clearAll = this.clearAll.bind(this); - var currentVersion = localStorage.getItem('local_storage_version'); - if (currentVersion !== global.window.config.Version) { - this.clear(); - localStorage.setItem('local_storage_version', global.window.config.Version); + var currentVersion = sessionStorage.getItem('storage_version'); + if (currentVersion !== global.window.mm_config.Version) { + sessionStorage.clear(); + sessionStorage.setItem('storage_version', global.window.mm_config.Version); } } getItem(name, defaultValue) { - return this.getGlobalItem(getPrefix() + name, defaultValue); + var result = null; + try { + result = JSON.parse(sessionStorage.getItem(getPrefix() + name)); + } catch (err) { + result = null; + } + + if (result === null && typeof defaultValue !== 'undefined') { + result = defaultValue; + } + + return result; } setItem(name, value) { - this.setGlobalItem(getPrefix() + name, value); + sessionStorage.setItem(getPrefix() + name, JSON.stringify(value)); } removeItem(name) { - localStorage.removeItem(getPrefix() + name); + sessionStorage.removeItem(getPrefix() + name); } setGlobalItem(name, value) { try { - localStorage.setItem(name, JSON.stringify(value)); + if (this.isLocalStorageSupported()) { + localStorage.setItem(getPrefix() + name, JSON.stringify(value)); + } else { + sessionStorage.setItem(getPrefix() + name, JSON.stringify(value)); + } } catch (err) { console.log('An error occurred while setting local storage, clearing all props'); //eslint-disable-line no-console localStorage.clear(); + sessionStorage.clear(); window.location.href = window.location.href; } } @@ -53,7 +73,11 @@ class BrowserStoreClass { getGlobalItem(name, defaultValue) { var result = null; try { - result = JSON.parse(localStorage.getItem(name)); + if (this.isLocalStorageSupported()) { + result = JSON.parse(localStorage.getItem(getPrefix() + name)); + } else { + result = JSON.parse(sessionStorage.getItem(getPrefix() + name)); + } } catch (err) { result = null; } @@ -66,22 +90,46 @@ class BrowserStoreClass { } removeGlobalItem(name) { - localStorage.removeItem(name); + if (this.isLocalStorageSupported()) { + localStorage.removeItem(getPrefix() + name); + } else { + sessionStorage.removeItem(getPrefix() + name); + } } - clear() { - localStorage.clear(); - sessionStorage.clear(); + getLastServerVersion() { + return sessionStorage.getItem('last_server_version'); + } + + setLastServerVersion(version) { + sessionStorage.setItem('last_server_version', version); } /** * Preforms the given action on each item that has the given prefix * Signature for action is action(key, value) */ + actionOnGlobalItemsWithPrefix(prefix, action) { + var globalPrefix = getPrefix(); + var globalPrefixiLen = globalPrefix.length; + + var storage = sessionStorage; + if (this.isLocalStorageSupported()) { + storage = localStorage; + } + + for (var key in storage) { + if (key.lastIndexOf(globalPrefix + prefix, 0) === 0) { + var userkey = key.substring(globalPrefixiLen); + action(userkey, this.getGlobalItem(key)); + } + } + } + actionOnItemsWithPrefix(prefix, action) { var globalPrefix = getPrefix(); var globalPrefixiLen = globalPrefix.length; - for (var key in localStorage) { + for (var key in sessionStorage) { if (key.lastIndexOf(globalPrefix + prefix, 0) === 0) { var userkey = key.substring(globalPrefixiLen); action(userkey, this.getGlobalItem(key)); @@ -89,6 +137,15 @@ class BrowserStoreClass { } } + clear() { + sessionStorage.clear(); + } + + clearAll() { + sessionStorage.clear(); + localStorage.clear(); + } + isLocalStorageSupported() { try { sessionStorage.setItem('testSession', '1'); diff --git a/web/react/stores/error_store.jsx b/web/react/stores/error_store.jsx index a4c42dcb7..775b8e006 100644 --- a/web/react/stores/error_store.jsx +++ b/web/react/stores/error_store.jsx @@ -34,9 +34,11 @@ class ErrorStoreClass extends EventEmitter { removeChangeListener(callback) { this.removeListener(CHANGE_EVENT, callback); } + handledError() { BrowserStore.removeItem('last_error'); } + getLastError() { return BrowserStore.getItem('last_error'); } diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 8609d8bbf..4a9314b31 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -230,7 +230,7 @@ class PostStoreClass extends EventEmitter { getPosts(channelId) { return BrowserStore.getItem('posts_' + channelId); } - getCurrentUsersLatestPost(channelId) { + getCurrentUsersLatestPost(channelId, rootId) { const userId = UserStore.getCurrentId(); var postList = makePostListNonNull(this.getPosts(channelId)); var i = 0; @@ -239,8 +239,15 @@ class PostStoreClass extends EventEmitter { for (i; i < len; i++) { if (postList.posts[postList.order[i]].user_id === userId) { - lastPost = postList.posts[postList.order[i]]; - break; + if (rootId) { + if (postList.posts[postList.order[i]].root_id === rootId || postList.posts[postList.order[i]].id === rootId) { + lastPost = postList.posts[postList.order[i]]; + break; + } + } else { + lastPost = postList.posts[postList.order[i]]; + break; + } } } @@ -317,10 +324,10 @@ class PostStoreClass extends EventEmitter { return 0; }); - BrowserStore.setItem('pending_posts_' + channelId, postList); + BrowserStore.setGlobalItem('pending_posts_' + channelId, postList); } getPendingPosts(channelId) { - return BrowserStore.getItem('pending_posts_' + channelId); + return BrowserStore.getGlobalItem('pending_posts_' + channelId); } storeUnseenDeletedPost(post) { var posts = this.getUnseenDeletedPosts(post.channel_id); @@ -364,7 +371,7 @@ class PostStoreClass extends EventEmitter { this.pStorePendingPosts(channelId, postList); } clearPendingPosts() { - BrowserStore.actionOnItemsWithPrefix('pending_posts_', function clearPending(key) { + BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', function clearPending(key) { BrowserStore.removeItem(key); }); } @@ -407,26 +414,26 @@ class PostStoreClass extends EventEmitter { } storeCurrentDraft(draft) { var channelId = ChannelStore.getCurrentId(); - BrowserStore.setItem('draft_' + channelId, draft); + BrowserStore.setGlobalItem('draft_' + channelId, draft); } getCurrentDraft() { var channelId = ChannelStore.getCurrentId(); return this.getDraft(channelId); } storeDraft(channelId, draft) { - BrowserStore.setItem('draft_' + channelId, draft); + BrowserStore.setGlobalItem('draft_' + channelId, draft); } getDraft(channelId) { - return BrowserStore.getItem('draft_' + channelId, this.getEmptyDraft()); + return BrowserStore.getGlobalItem('draft_' + channelId, this.getEmptyDraft()); } storeCommentDraft(parentPostId, draft) { - BrowserStore.setItem('comment_draft_' + parentPostId, draft); + BrowserStore.setGlobalItem('comment_draft_' + parentPostId, draft); } getCommentDraft(parentPostId) { - return BrowserStore.getItem('comment_draft_' + parentPostId, this.getEmptyDraft()); + return BrowserStore.getGlobalItem('comment_draft_' + parentPostId, this.getEmptyDraft()); } clearDraftUploads() { - BrowserStore.actionOnItemsWithPrefix('draft_', function clearUploads(key, value) { + BrowserStore.actionOnGlobalItemsWithPrefix('draft_', function clearUploads(key, value) { if (value) { value.uploadsInProgress = []; BrowserStore.setItem(key, value); @@ -434,7 +441,7 @@ class PostStoreClass extends EventEmitter { }); } clearCommentDraftUploads() { - BrowserStore.actionOnItemsWithPrefix('comment_draft_', function clearUploads(key, value) { + BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', function clearUploads(key, value) { if (value) { value.uploadsInProgress = []; BrowserStore.setItem(key, value); diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 77e7067ad..9410c1e9c 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -1,15 +1,22 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var UserStore = require('./user_store.jsx'); -var ErrorStore = require('./error_store.jsx'); -var EventEmitter = require('events').EventEmitter; +const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +const UserStore = require('./user_store.jsx'); +const PostStore = require('./post_store.jsx'); +const ChannelStore = require('./channel_store.jsx'); +const BrowserStore = require('./browser_store.jsx'); +const ErrorStore = require('./error_store.jsx'); +const EventEmitter = require('events').EventEmitter; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +const Utils = require('../utils/utils.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); -var CHANGE_EVENT = 'change'; +const Constants = require('../utils/constants.jsx'); +const ActionTypes = Constants.ActionTypes; +const SocketEvents = Constants.SocketEvents; + +const CHANGE_EVENT = 'change'; var conn; @@ -31,6 +38,10 @@ class SocketStoreClass extends EventEmitter { return; } + if (!global.window.hasOwnProperty('mm_session_token_index')) { + return; + } + this.setMaxListeners(0); if (window.WebSocket && !conn) { @@ -38,7 +49,9 @@ class SocketStoreClass extends EventEmitter { if (window.location.protocol === 'https:') { protocol = 'wss://'; } - var connUrl = protocol + location.host + '/api/v1/websocket'; + + var connUrl = protocol + location.host + '/api/v1/websocket?' + Utils.getSessionIndex(); + if (this.failCount === 0) { console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console } @@ -94,6 +107,39 @@ class SocketStoreClass extends EventEmitter { removeChangeListener(callback) { this.removeListener(CHANGE_EVENT, callback); } + handleMessage(msg) { + switch (msg.action) { + case SocketEvents.POSTED: + handleNewPostEvent(msg); + break; + + case SocketEvents.POST_EDITED: + handlePostEditEvent(msg); + break; + + case SocketEvents.POST_DELETED: + handlePostDeleteEvent(msg); + break; + + case SocketEvents.NEW_USER: + handleNewUserEvent(); + break; + + case SocketEvents.USER_ADDED: + handleUserAddedEvent(msg); + break; + + case SocketEvents.USER_REMOVED: + handleUserRemovedEvent(msg); + break; + + case SocketEvents.CHANNEL_VIEWED: + handleChannelViewedEvent(msg); + break; + + default: + } + } sendMessage(msg) { if (conn && conn.readyState === WebSocket.OPEN) { conn.send(JSON.stringify(msg)); @@ -104,6 +150,138 @@ class SocketStoreClass extends EventEmitter { } } +function handleNewPostEvent(msg) { + // Store post + const post = JSON.parse(msg.props.post); + PostStore.storePost(post); + + // Update channel state + if (ChannelStore.getCurrentId() === msg.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(true); + } + } else { + AsyncClient.getChannel(msg.channel_id); + } + + // Send desktop notification + if (UserStore.getCurrentId() !== msg.user_id) { + const msgProps = msg.props; + + let mentions = []; + if (msgProps.mentions) { + mentions = JSON.parse(msg.props.mentions); + } + + const channel = ChannelStore.get(msg.channel_id); + const user = UserStore.getCurrentUser(); + const member = ChannelStore.getMember(msg.channel_id); + + let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; + if (notifyLevel === 'default') { + notifyLevel = user.notify_props.desktop; + } + + if (notifyLevel === 'none') { + return; + } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== 'D') { + return; + } + + let username = 'Someone'; + if (UserStore.hasProfile(msg.user_id)) { + username = UserStore.getProfile(msg.user_id).username; + } + + let title = 'Posted'; + if (channel) { + title = channel.display_name; + } + + let notifyText = post.message.replace(/\n+/g, ' '); + if (notifyText.length > 50) { + notifyText = notifyText.substring(0, 49) + '...'; + } + + if (notifyText.length === 0) { + if (msgProps.image) { + Utils.notifyMe(title, username + ' uploaded an image', channel); + } else if (msgProps.otherFile) { + Utils.notifyMe(title, username + ' uploaded a file', channel); + } else { + Utils.notifyMe(title, username + ' did something new', channel); + } + } else { + Utils.notifyMe(title, username + ' wrote: ' + notifyText, channel); + } + if (!user.notify_props || user.notify_props.desktop_sound === 'true') { + Utils.ding(); + } + } +} + +function handlePostEditEvent(msg) { + // Store post + const post = JSON.parse(msg.props.post); + PostStore.storePost(post); + + // Update channel state + if (ChannelStore.getCurrentId() === msg.channel_id) { + if (window.isActive) { + AsyncClient.updateLastViewedAt(); + } + } +} + +function handlePostDeleteEvent(msg) { + const post = JSON.parse(msg.props.post); + + PostStore.storeUnseenDeletedPost(post); + PostStore.removePost(post, true); + PostStore.emitChange(); +} + +function handleNewUserEvent() { + AsyncClient.getProfiles(); + AsyncClient.getChannelExtraInfo(true); +} + +function handleUserAddedEvent(msg) { + if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(true); + } + + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + +function handleUserRemovedEvent(msg) { + if (UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannels(); + + if (msg.props.remover_id !== msg.user_id && + msg.channel_id === ChannelStore.getCurrentId() && + $('#removed_from_channel').length > 0) { + var sentState = {}; + sentState.channelName = ChannelStore.getCurrent().display_name; + sentState.remover = UserStore.getProfile(msg.props.remover_id).username; + + BrowserStore.setItem('channel-removed-state', sentState); + $('#removed_from_channel').modal('show'); + } + } else if (ChannelStore.getCurrentId() === msg.channel_id) { + AsyncClient.getChannelExtraInfo(true); + } +} + +function handleChannelViewedEvent(msg) { + // Useful for when multiple devices have the app open to different channels + if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) { + AsyncClient.getChannel(msg.channel_id); + } +} + var SocketStore = new SocketStoreClass(); SocketStore.dispatchToken = AppDispatcher.register((payload) => { @@ -111,6 +289,7 @@ SocketStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECIEVED_MSG: + SocketStore.handleMessage(action.msg); SocketStore.emitChange(action.msg); break; diff --git a/web/react/stores/team_store.jsx b/web/react/stores/team_store.jsx index 7001acdb1..22114ae85 100644 --- a/web/react/stores/team_store.jsx +++ b/web/react/stores/team_store.jsx @@ -28,29 +28,31 @@ class TeamStoreClass extends EventEmitter { this.get = this.get.bind(this); this.getByName = this.getByName.bind(this); this.getAll = this.getAll.bind(this); - this.setCurrentId = this.setCurrentId.bind(this); this.getCurrentId = this.getCurrentId.bind(this); this.getCurrent = this.getCurrent.bind(this); this.getCurrentTeamUrl = this.getCurrentTeamUrl.bind(this); - this.storeTeam = this.storeTeam.bind(this); - this.pStoreTeams = this.pStoreTeams.bind(this); - this.pGetTeams = this.pGetTeams.bind(this); + this.saveTeam = this.saveTeam.bind(this); } + emitChange() { this.emit(CHANGE_EVENT); } + addChangeListener(callback) { this.on(CHANGE_EVENT, callback); } + removeChangeListener(callback) { this.removeListener(CHANGE_EVENT, callback); } + get(id) { - var c = this.pGetTeams(); + var c = this.getAll(); return c[id]; } + getByName(name) { - var t = this.pGetTeams(); + var t = this.getAll(); for (var id in t) { if (t[id].name === name) { @@ -60,59 +62,51 @@ class TeamStoreClass extends EventEmitter { return null; } + getAll() { - return this.pGetTeams(); - } - setCurrentId(id) { - if (id === null) { - BrowserStore.removeItem('current_team_id'); - } else { - BrowserStore.setItem('current_team_id', id); - } + return BrowserStore.getItem('user_teams', {}); } + getCurrentId() { - return BrowserStore.getItem('current_team_id'); - } - getCurrent() { - var currentId = this.getCurrentId(); + var team = global.window.mm_team; - if (currentId !== null) { - return this.get(currentId); + if (team) { + return team.id; } + return null; } + + getCurrent() { + if (global.window.mm_team != null && this.get(global.window.mm_team.id) == null) { + this.saveTeam(global.window.mm_team); + } + + return global.window.mm_team; + } + getCurrentTeamUrl() { if (this.getCurrent()) { return getWindowLocationOrigin() + '/' + this.getCurrent().name; } return null; } - storeTeam(team) { - var teams = this.pGetTeams(); + + saveTeam(team) { + var teams = this.getAll(); teams[team.id] = team; - this.pStoreTeams(teams); - } - pStoreTeams(teams) { BrowserStore.setItem('user_teams', teams); } - pGetTeams() { - return BrowserStore.getItem('user_teams', {}); - } } var TeamStore = new TeamStoreClass(); -TeamStore.dispatchToken = AppDispatcher.register(function registry(payload) { +TeamStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.CLICK_TEAM: - TeamStore.setCurrentId(action.id); - TeamStore.emitChange(); - break; - case ActionTypes.RECIEVED_TEAM: - TeamStore.storeTeam(action.team); + TeamStore.saveTeam(action.team); TeamStore.emitChange(); break; diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index fa74f812d..ce80c5ec9 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -3,7 +3,6 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var EventEmitter = require('events').EventEmitter; -var client = require('../utils/client.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -38,23 +37,19 @@ class UserStoreClass extends EventEmitter { this.emitToggleImportModal = this.emitToggleImportModal.bind(this); this.addImportModalListener = this.addImportModalListener.bind(this); this.removeImportModalListener = this.removeImportModalListener.bind(this); - this.setCurrentId = this.setCurrentId.bind(this); this.getCurrentId = this.getCurrentId.bind(this); this.getCurrentUser = this.getCurrentUser.bind(this); this.setCurrentUser = this.setCurrentUser.bind(this); this.getLastEmail = this.getLastEmail.bind(this); this.setLastEmail = this.setLastEmail.bind(this); - this.removeCurrentUser = this.removeCurrentUser.bind(this); this.hasProfile = this.hasProfile.bind(this); this.getProfile = this.getProfile.bind(this); this.getProfileByUsername = this.getProfileByUsername.bind(this); this.getProfilesUsernameMap = this.getProfilesUsernameMap.bind(this); this.getProfiles = this.getProfiles.bind(this); this.getActiveOnlyProfiles = this.getActiveOnlyProfiles.bind(this); + this.getActiveOnlyProfileList = this.getActiveOnlyProfileList.bind(this); this.saveProfile = this.saveProfile.bind(this); - this.pStoreProfiles = this.pStoreProfiles.bind(this); - this.pGetProfiles = this.pGetProfiles.bind(this); - this.pGetProfilesUsernameMap = this.pGetProfilesUsernameMap.bind(this); this.setSessions = this.setSessions.bind(this); this.getSessions = this.getSessions.bind(this); this.setAudits = this.setAudits.bind(this); @@ -62,138 +57,155 @@ class UserStoreClass extends EventEmitter { this.setTeams = this.setTeams.bind(this); this.getTeams = this.getTeams.bind(this); this.getCurrentMentionKeys = this.getCurrentMentionKeys.bind(this); - this.getLastVersion = this.getLastVersion.bind(this); - this.setLastVersion = this.setLastVersion.bind(this); this.setStatuses = this.setStatuses.bind(this); this.pSetStatuses = this.pSetStatuses.bind(this); this.setStatus = this.setStatus.bind(this); this.getStatuses = this.getStatuses.bind(this); this.getStatus = this.getStatus.bind(this); - - this.gCurrentId = null; } emitChange(userId) { this.emit(CHANGE_EVENT, userId); } + addChangeListener(callback) { this.on(CHANGE_EVENT, callback); } + removeChangeListener(callback) { this.removeListener(CHANGE_EVENT, callback); } + emitSessionsChange() { this.emit(CHANGE_EVENT_SESSIONS); } + addSessionsChangeListener(callback) { this.on(CHANGE_EVENT_SESSIONS, callback); } + removeSessionsChangeListener(callback) { this.removeListener(CHANGE_EVENT_SESSIONS, callback); } + emitAuditsChange() { this.emit(CHANGE_EVENT_AUDITS); } + addAuditsChangeListener(callback) { this.on(CHANGE_EVENT_AUDITS, callback); } + removeAuditsChangeListener(callback) { this.removeListener(CHANGE_EVENT_AUDITS, callback); } + emitTeamsChange() { this.emit(CHANGE_EVENT_TEAMS); } + addTeamsChangeListener(callback) { this.on(CHANGE_EVENT_TEAMS, callback); } + removeTeamsChangeListener(callback) { this.removeListener(CHANGE_EVENT_TEAMS, callback); } + emitStatusesChange() { this.emit(CHANGE_EVENT_STATUSES); } + addStatusesChangeListener(callback) { this.on(CHANGE_EVENT_STATUSES, callback); } + removeStatusesChangeListener(callback) { this.removeListener(CHANGE_EVENT_STATUSES, callback); } + emitToggleImportModal(value) { this.emit(TOGGLE_IMPORT_MODAL_EVENT, value); } + addImportModalListener(callback) { this.on(TOGGLE_IMPORT_MODAL_EVENT, callback); } + removeImportModalListener(callback) { this.removeListener(TOGGLE_IMPORT_MODAL_EVENT, callback); } - setCurrentId(id) { - this.gCurrentId = id; - if (id == null) { - BrowserStore.removeGlobalItem('current_user_id'); - } else { - BrowserStore.setGlobalItem('current_user_id', id); + + getCurrentUser() { + if (this.getProfiles()[global.window.mm_user.id] == null) { + this.saveProfile(global.window.mm_user); } + + return global.window.mm_user; } - getCurrentId(skipFetch) { - var currentId = this.gCurrentId; - if (currentId == null) { - currentId = BrowserStore.getGlobalItem('current_user_id'); - this.gCurrentId = currentId; - } + setCurrentUser(user) { + var oldUser = global.window.mm_user; - // this is a special case to force fetch the - // current user if it's missing - // it's synchronous to block rendering - if (currentId == null && !skipFetch) { - var me = client.getMeSynchronous(); - if (me != null) { - this.setCurrentUser(me); - currentId = me.id; - } + if (oldUser.id === user.id) { + global.window.mm_user = user; + this.saveProfile(user); + } else { + throw new Error('Problem with setCurrentUser old_user_id=' + oldUser.id + ' new_user_id=' + user.id); } - - return currentId; } - getCurrentUser() { - if (this.getCurrentId() == null) { - return null; + + getCurrentId() { + var user = global.window.mm_user; + + if (user) { + return user.id; } - return this.pGetProfiles()[this.getCurrentId()]; - } - setCurrentUser(user) { - this.setCurrentId(user.id); - this.saveProfile(user); + return null; } + getLastEmail() { - return BrowserStore.getItem('last_email', ''); + return BrowserStore.getGlobalItem('last_email', ''); } + setLastEmail(email) { - BrowserStore.setItem('last_email', email); - } - removeCurrentUser() { - this.setCurrentId(null); + BrowserStore.setGlobalItem('last_email', email); } + hasProfile(userId) { - return this.pGetProfiles()[userId] != null; + return this.getProfiles()[userId] != null; } + getProfile(userId) { - return this.pGetProfiles()[userId]; + return this.getProfiles()[userId]; } + getProfileByUsername(username) { - return this.pGetProfilesUsernameMap()[username]; + return this.getProfilesUsernameMap()[username]; } + getProfilesUsernameMap() { - return this.pGetProfilesUsernameMap(); + var profileUsernameMap = {}; + + var profiles = this.getProfiles(); + for (var key in profiles) { + if (profiles.hasOwnProperty(key)) { + var profile = profiles[key]; + profileUsernameMap[profile.username] = profile; + } + } + + return profileUsernameMap; } + getProfiles() { - return this.pGetProfiles(); + return BrowserStore.getItem('profiles', {}); } + getActiveOnlyProfiles() { var active = {}; - var current = this.pGetProfiles(); + var current = this.getProfiles(); for (var key in current) { if (current[key].delete_at === 0) { @@ -203,45 +215,50 @@ class UserStoreClass extends EventEmitter { return active; } - saveProfile(profile) { - var ps = this.pGetProfiles(); - ps[profile.id] = profile; - this.pStoreProfiles(ps); - } - pStoreProfiles(profiles) { - BrowserStore.setItem('profiles', profiles); - var profileUsernameMap = {}; - for (var id in profiles) { - if (profiles.hasOwnProperty(id)) { - profileUsernameMap[profiles[id].username] = profiles[id]; + + getActiveOnlyProfileList() { + const profileMap = this.getActiveOnlyProfiles(); + const profiles = []; + + for (const id in profileMap) { + if (profileMap.hasOwnProperty(id)) { + profiles.push(profileMap[id]); } } - BrowserStore.setItem('profileUsernameMap', profileUsernameMap); - } - pGetProfiles() { - return BrowserStore.getItem('profiles', {}); + + return profiles; } - pGetProfilesUsernameMap() { - return BrowserStore.getItem('profileUsernameMap', {}); + + saveProfile(profile) { + var ps = this.getProfiles(); + ps[profile.id] = profile; + BrowserStore.setItem('profiles', ps); } + setSessions(sessions) { BrowserStore.setItem('sessions', sessions); } + getSessions() { return BrowserStore.getItem('sessions', {loading: true}); } + setAudits(audits) { BrowserStore.setItem('audits', audits); } + getAudits() { return BrowserStore.getItem('audits', {loading: true}); } + setTeams(teams) { BrowserStore.setItem('teams', teams); } + getTeams() { return BrowserStore.getItem('teams', []); } + getCurrentMentionKeys() { var user = this.getCurrentUser(); @@ -269,28 +286,27 @@ class UserStoreClass extends EventEmitter { return keys; } - getLastVersion() { - return BrowserStore.getItem('last_version', ''); - } - setLastVersion(version) { - BrowserStore.setItem('last_version', version); - } + setStatuses(statuses) { this.pSetStatuses(statuses); this.emitStatusesChange(); } + pSetStatuses(statuses) { BrowserStore.setItem('statuses', statuses); } + setStatus(userId, status) { var statuses = this.getStatuses(); statuses[userId] = status; this.pSetStatuses(statuses); this.emitStatusesChange(); } + getStatuses() { return BrowserStore.getItem('statuses', {}); } + getStatus(id) { return this.getStatuses()[id]; } @@ -299,7 +315,7 @@ class UserStoreClass extends EventEmitter { var UserStore = new UserStoreClass(); UserStore.setMaxListeners(0); -UserStore.dispatchToken = AppDispatcher.register(function registry(payload) { +UserStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index b22d7237e..b1bc71d54 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -3,6 +3,7 @@ var client = require('./client.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +var BrowserStore = require('../stores/browser_store.jsx'); var ChannelStore = require('../stores/channel_store.jsx'); var PostStore = require('../stores/post_store.jsx'); var UserStore = require('../stores/user_store.jsx'); @@ -50,18 +51,18 @@ export function getChannels(force, updateLastViewed, checkVersion) { callTracker.getChannels = utils.getTimestamp(); client.getChannels( - function getChannelsSuccess(data, textStatus, xhr) { + (data, textStatus, xhr) => { callTracker.getChannels = 0; if (checkVersion) { var serverVersion = xhr.getResponseHeader('X-Version-ID'); - if (!UserStore.getLastVersion()) { - UserStore.setLastVersion(serverVersion); + if (!BrowserStore.getLastServerVersion()) { + BrowserStore.setLastServerVersion(serverVersion); } - if (serverVersion !== UserStore.getLastVersion()) { - UserStore.setLastVersion(serverVersion); + if (serverVersion !== BrowserStore.getLastServerVersion()) { + BrowserStore.setLastServerVersion(serverVersion); window.location.href = window.location.href; console.log('Detected version update refreshing the page'); //eslint-disable-line no-console } @@ -77,7 +78,7 @@ export function getChannels(force, updateLastViewed, checkVersion) { members: data.members }); }, - function getChannelsFailure(err) { + (err) => { callTracker.getChannels = 0; dispatchError(err, 'getChannels'); } @@ -151,14 +152,14 @@ export function getChannel(id) { ); } -export function updateLastViewedAt() { +export function updateLastViewedAt(force) { const channelId = ChannelStore.getCurrentId(); if (channelId === null) { return; } - if (isCallInProgress(`updateLastViewed${channelId}`)) { + if (isCallInProgress(`updateLastViewed${channelId}`) && !force) { return; } @@ -566,8 +567,8 @@ export function getMe() { } callTracker.getMe = utils.getTimestamp(); - client.getMeSynchronous( - function getMeSyncSuccess(data, textStatus, xhr) { + client.getMe( + (data, textStatus, xhr) => { callTracker.getMe = 0; if (xhr.status === 304 || !data) { @@ -579,7 +580,7 @@ export function getMe() { me: data }); }, - function getMeSyncFailure(err) { + (err) => { callTracker.getMe = 0; dispatchError(err, 'getMe'); } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index f6aee362c..bc73f3c64 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -4,8 +4,8 @@ var BrowserStore = require('../stores/browser_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var ErrorStore = require('../stores/error_store.jsx'); -export function track(category, action, label, prop, val) { - global.window.analytics.track(action, {category: category, label: label, property: prop, value: val}); +export function track(category, action, label, property, value) { + global.window.analytics.track(action, {category, label, property, value}); } export function trackPage() { @@ -232,6 +232,7 @@ export function logout() { track('api', 'api_users_logout'); var currentTeamUrl = TeamStore.getCurrentTeamUrl(); BrowserStore.clear(); + ErrorStore.storeLastError(null); window.location.href = currentTeamUrl + '/logout'; } @@ -385,10 +386,9 @@ export function getAllTeams(success, error) { }); } -export function getMeSynchronous(success, error) { +export function getMe(success, error) { var currentUser = null; $.ajax({ - async: false, cache: false, url: '/api/v1/users/me', dataType: 'json', @@ -402,7 +402,7 @@ export function getMeSynchronous(success, error) { }, error: function onError(xhr, status, err) { if (error) { - var e = handleError('getMeSynchronous', xhr, status, err); + var e = handleError('getMe', xhr, status, err); error(e); } } @@ -1182,3 +1182,61 @@ export function savePreferences(preferences, success, error) { } }); } + +export function addOutgoingHook(hook, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/create', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(hook), + success, + error: (xhr, status, err) => { + var e = handleError('addOutgoingHook', xhr, status, err); + error(e); + } + }); +} + +export function deleteOutgoingHook(data, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/delete', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: (xhr, status, err) => { + var e = handleError('deleteOutgoingHook', xhr, status, err); + error(e); + } + }); +} + +export function listOutgoingHooks(success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/list', + dataType: 'json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('listOutgoingHooks', xhr, status, err); + error(e); + } + }); +} + +export function regenOutgoingHookToken(data, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/regen_token', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: (xhr, status, err) => { + var e = handleError('regenOutgoingHookToken', xhr, status, err); + error(e); + } + }); +} diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index b7b8d3c60..72773bf05 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -33,7 +33,6 @@ module.exports = { RECIEVED_MSG: null, - CLICK_TEAM: null, RECIEVED_TEAM: null, RECIEVED_CONFIG: null, @@ -47,6 +46,18 @@ module.exports = { SERVER_ACTION: null, VIEW_ACTION: null }), + + SocketEvents: { + POSTED: 'posted', + POST_EDITED: 'post_edited', + POST_DELETED: 'post_deleted', + CHANNEL_VIEWED: 'channel_viewed', + NEW_USER: 'new_user', + USER_ADDED: 'user_added', + USER_REMOVED: 'user_removed', + TYPING: 'typing' + }, + SPECIAL_MENTIONS: ['all', 'channel'], CHARACTER_LIMIT: 4000, IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'], @@ -114,6 +125,7 @@ module.exports = { MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], MAX_DMS: 20, DM_CHANNEL: 'D', + OPEN_CHANNEL: 'O', MAX_POST_LEN: 4000, EMOJI_SIZE: 16, ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>", @@ -127,7 +139,7 @@ module.exports = { sidebarText: '#333333', sidebarUnreadText: '#333333', sidebarTextHoverBg: '#e6f2fa', - sidebarTextActiveBg: '#e1e1e1', + sidebarTextActiveBorder: '#378FD2', sidebarTextActiveColor: '#111111', sidebarHeaderBg: '#2389d7', sidebarHeaderTextColor: '#ffffff', @@ -149,7 +161,7 @@ module.exports = { sidebarText: '#fff', sidebarUnreadText: '#fff', sidebarTextHoverBg: '#136197', - sidebarTextActiveBg: '#136197', + sidebarTextActiveBorder: '#7AB0D6', sidebarTextActiveColor: '#FFFFFF', sidebarHeaderBg: '#2f81b7', sidebarHeaderTextColor: '#FFFFFF', @@ -171,7 +183,7 @@ module.exports = { sidebarText: '#fff', sidebarUnreadText: '#fff', sidebarTextHoverBg: '#4A5664', - sidebarTextActiveBg: '#39769C', + sidebarTextActiveBorder: '#39769C', sidebarTextActiveColor: '#FFFFFF', sidebarHeaderBg: '#1B2C3E', sidebarHeaderTextColor: '#FFFFFF', @@ -193,7 +205,7 @@ module.exports = { sidebarText: '#fff', sidebarUnreadText: '#fff', sidebarTextHoverBg: '#302e30', - sidebarTextActiveBg: '#484748', + sidebarTextActiveBorder: '#196CAF', sidebarTextActiveColor: '#FFFFFF', sidebarHeaderBg: '#1f1f1f', sidebarHeaderTextColor: '#FFFFFF', @@ -236,8 +248,8 @@ module.exports = { uiName: 'Sidebar Text Hover BG' }, { - id: 'sidebarTextActiveBg', - uiName: 'Sidebar Text Active BG' + id: 'sidebarTextActiveBorder', + uiName: 'Sidebar Text Active Border' }, { id: 'sidebarTextActiveColor', @@ -299,6 +311,7 @@ module.exports = { RIGHT: 39, BACKSPACE: 8, ENTER: 13, - ESCAPE: 27 + ESCAPE: 27, + SPACE: 32 } }; diff --git a/web/react/utils/emoticons.jsx b/web/react/utils/emoticons.jsx index 94bb91503..bb948b6dc 100644 --- a/web/react/utils/emoticons.jsx +++ b/web/react/utils/emoticons.jsx @@ -2,26 +2,27 @@ // See License.txt for license information. const emoticonPatterns = { - smile: /(^|\s)(:-?\))($|\s)/g, // :) - open_mouth: /(^|\s)(:o)($|\s)/gi, // :o - scream: /(^|\s)(:-o)($|\s)/gi, // :-o - smirk: /(^|\s)(:-?])($|\s)/g, // :] - grinning: /(^|\s)(:-?d)($|\s)/gi, // :D - stuck_out_tongue_closed_eyes: /(^|\s)(x-d)($|\s)/gi, // x-d - stuck_out_tongue: /(^|\s)(:-?p)($|\s)/gi, // :p - rage: /(^|\s)(:-?[\[@])($|\s)/g, // :@ - frowning: /(^|\s)(:-?\()($|\s)/g, // :( - sob: /(^|\s)(:['’]-?\(|:'\(|:'\()($|\s)/g, // :`( - kissing_heart: /(^|\s)(:-?\*)($|\s)/g, // :* - pensive: /(^|\s)(:-?\/)($|\s)/g, // :/ - confounded: /(^|\s)(:-?s)($|\s)/gi, // :s - flushed: /(^|\s)(:-?\|)($|\s)/g, // :| - relaxed: /(^|\s)(:-?\$)($|\s)/g, // :$ - mask: /(^|\s)(:-x)($|\s)/gi, // :-x - heart: /(^|\s)(<3|<3)($|\s)/g, // <3 - broken_heart: /(^|\s)(<\/3|</3)($|\s)/g, // </3 - thumbsup: /(^|\s)(:\+1:)($|\s)/g, // :+1: - thumbsdown: /(^|\s)(:\-1:)($|\s)/g // :-1: + smile: /(^|\s)(:-?\))(?=$|\s)/g, // :) + wink: /(^|\s)(;-?\))(?=$|\s)/g, // ;) + open_mouth: /(^|\s)(:o)(?=$|\s)/gi, // :o + scream: /(^|\s)(:-o)(?=$|\s)/gi, // :-o + smirk: /(^|\s)(:-?])(?=$|\s)/g, // :] + grinning: /(^|\s)(:-?d)(?=$|\s)/gi, // :D + stuck_out_tongue_closed_eyes: /(^|\s)(x-d)(?=$|\s)/gi, // x-d + stuck_out_tongue: /(^|\s)(:-?p)(?=$|\s)/gi, // :p + rage: /(^|\s)(:-?[\[@])(?=$|\s)/g, // :@ + frowning: /(^|\s)(:-?\()(?=$|\s)/g, // :( + sob: /(^|\s)(:['’]-?\(|:'\(|:'\()(?=$|\s)/g, // :`( + kissing_heart: /(^|\s)(:-?\*)(?=$|\s)/g, // :* + pensive: /(^|\s)(:-?\/)(?=$|\s)/g, // :/ + confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s + flushed: /(^|\s)(:-?\|)(?=$|\s)/g, // :| + relaxed: /(^|\s)(:-?\$)(?=$|\s)/g, // :$ + mask: /(^|\s)(:-x)(?=$|\s)/gi, // :-x + heart: /(^|\s)(<3|<3)(?=$|\s)/g, // <3 + broken_heart: /(^|\s)(<\/3|</3)(?=$|\s)/g, // </3 + thumbsup: /(^|\s)(:\+1:)(?=$|\s)/g, // :+1: + thumbsdown: /(^|\s)(:\-1:)(?=$|\s)/g // :-1: }; function initializeEmoticonMap() { @@ -126,28 +127,28 @@ const emoticonMap = initializeEmoticonMap(); export function handleEmoticons(text, tokens) { let output = text; - function replaceEmoticonWithToken(match, prefix, name, suffix) { + function replaceEmoticonWithToken(fullMatch, prefix, matchText, name) { if (emoticonMap[name]) { const index = tokens.size; const alias = `MM_EMOTICON${index}`; tokens.set(alias, { - value: `<img align="absmiddle" alt=${match} class="emoji" src=${getImagePathForEmoticon(name)} title=${match} />`, - originalText: match + value: `<img align="absmiddle" alt="${matchText}" class="emoji" src="${getImagePathForEmoticon(name)}" title="${matchText}" />`, + originalText: fullMatch }); - return prefix + alias + suffix; + return prefix + alias; } - return match; + return fullMatch; } - output = output.replace(/(^|\s):([a-zA-Z0-9_-]+):($|\s)/g, replaceEmoticonWithToken); + output = output.replace(/(^|\s)(:([a-zA-Z0-9_-]+):)(?=$|\s)/g, (fullMatch, prefix, matchText, name) => replaceEmoticonWithToken(fullMatch, prefix, matchText, name)); $.each(emoticonPatterns, (name, pattern) => { // this might look a bit funny, but since the name isn't contained in the actual match // like with the named emoticons, we need to add it in manually - output = output.replace(pattern, (match, prefix, emoticon, suffix) => replaceEmoticonWithToken(match, prefix, name, suffix)); + output = output.replace(pattern, (fullMatch, prefix, matchText) => replaceEmoticonWithToken(fullMatch, prefix, matchText, name)); }); return output; diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 2813798d2..7a4e70054 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -11,6 +11,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer { super(options); this.heading = this.heading.bind(this); + this.paragraph = this.paragraph.bind(this); this.text = this.text.bind(this); this.formattingOptions = formattingOptions; @@ -53,11 +54,17 @@ export class MattermostMarkdownRenderer extends marked.Renderer { } paragraph(text) { + let outText = text; + + if (!('emoticons' in this.options) || this.options.emoticon) { + outText = TextFormatting.doFormatEmoticons(text); + } + if (this.formattingOptions.singleline) { - return `<p class="markdown__paragraph-inline">${text}</p>`; + return `<p class="markdown__paragraph-inline">${outText}</p>`; } - return super.paragraph(text); + return super.paragraph(outText); } table(header, body) { diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index d79aeed68..204c37364 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -69,6 +69,15 @@ export function doFormatText(text, options) { return output; } +export function doFormatEmoticons(text) { + const tokens = new Map(); + + let output = Emoticons.handleEmoticons(text, tokens); + output = replaceTokens(output, tokens); + + return output; +} + export function sanitizeHtml(text) { let output = text; @@ -237,7 +246,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { var newTokens = new Map(); for (const [alias, token] of tokens) { - if (token.originalText === searchTerm) { + if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) { const index = tokens.size + newTokens.size; const newAlias = `MM_SEARCHTERM${index}`; @@ -267,7 +276,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { return prefix + alias; } - return output.replace(new RegExp(`(^|\\W)(${searchTerm})\\b`, 'gi'), replaceSearchTermWithToken); + return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); } function replaceTokens(text, tokens) { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 561c2c4c4..67a9d6983 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -13,7 +13,8 @@ var client = require('./client.jsx'); var Autolinker = require('autolinker'); export function isEmail(email) { - var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/; + //var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/; + var regex = /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i; return regex.test(email); } @@ -117,7 +118,7 @@ export function notifyMe(title, body, channel) { } if (permission === 'granted') { - var notification = new Notification(title, {body: body, tag: body, icon: '/static/images/icon50x50.gif'}); + var notification = new Notification(title, {body: body, tag: body, icon: '/static/images/icon50x50.png'}); notification.onclick = function onClick() { window.focus(); if (channel) { @@ -424,16 +425,13 @@ export function applyTheme(theme) { changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li:hover a', 'background:' + theme.sidebarTextHoverBg, 1); } - if (theme.sidebarTextActiveBg) { - changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'background:' + theme.sidebarTextActiveBg, 1); + if (theme.sidebarTextActiveBorder) { + changeCss('.sidebar--left .nav li.active a:before, .settings-modal .nav-pills>li.active a:before', 'background:' + theme.sidebarTextActiveBorder, 1); } if (theme.sidebarTextActiveColor) { changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'color:' + theme.sidebarTextActiveColor, 2); - } - - if (theme.sidebarTextActiveBg === theme.onlineIndicator) { - changeCss('.sidebar--left .nav-pills__container li.active a .status .online--icon', 'fill:' + theme.sidebarTextActiveColor, 1); + changeCss('.sidebar--left .nav li.active a, .sidebar--left .nav li.active a:hover, .sidebar--left .nav li.active a:focus', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.1), 1); } if (theme.sidebarHeaderBg) { @@ -497,7 +495,7 @@ export function applyTheme(theme) { changeCss('.markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); changeCss('.channel-header #member_popover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); - changeCss('.custom-textarea, .custom-textarea:focus, .preview-container .preview-div, .post-image__column .post-image__details, .sidebar--right .sidebar-right__body, .markdown__table th, .markdown__table td, .command-box, .modal .modal-content, .settings-modal .settings-table .settings-content .divider-light, .dropdown-menu, .modal .modal-header, .popover, .mentions--top .mentions-box', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); + changeCss('.custom-textarea, .custom-textarea:focus, .preview-container .preview-div, .post-image__column .post-image__details, .sidebar--right .sidebar-right__body, .markdown__table th, .markdown__table td, .command-box, .modal .modal-content, .settings-modal .settings-table .settings-content .divider-light, .webhooks__container, .dropdown-menu, .modal .modal-header, .popover, .mentions--top .mentions-box', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.popover.bottom>.arrow', 'border-bottom-color:' + changeOpacity(theme.centerChannelColor, 0.25), 1); changeCss('.popover.right>.arrow', 'border-right-color:' + changeOpacity(theme.centerChannelColor, 0.25), 1); changeCss('.popover.left>.arrow', 'border-left-color:' + changeOpacity(theme.centerChannelColor, 0.25), 1); @@ -512,19 +510,19 @@ export function applyTheme(theme) { changeCss('@media(max-width: 768px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1); changeCss('.input-group-addon, .search-bar__container .search__form, .form-control', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); - changeCss('.channel-intro .channel-intro__content', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1); + changeCss('.channel-intro .channel-intro__content, .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1); changeCss('.date-separator .separator__text', 'color:' + theme.centerChannelColor, 2); changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.modal .custom-textarea:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.channel-intro, .settings-modal .settings-table .settings-content .divider-dark, hr, .settings-modal .settings-table .settings-links', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); - changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-channel-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); + changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); changeCss('@media(max-width: 1800px){.inner__wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); - changeCss('.post:hover, .modal .more-channel-table tbody>tr:hover td, .sidebar--right .sidebar--right__header, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); + changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .sidebar--right .sidebar--right__header, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.date-separator.hovered--before:after, .date-separator.hovered--after:before, .new-separator.hovered--after:before, .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); - changeCss('.command-name:hover, .mentions-name:hover, .mentions-focus, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); + changeCss('.command-name:hover, .mentions-name:hover, .mentions-focus, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover, .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); changeCss('code', 'background:' + changeOpacity(theme.centerChannelColor, 0.1), 1); changeCss('.post.current--user:hover .post-body ', 'background: none;', 1); changeCss('.sidebar--right', 'color:' + theme.centerChannelColor, 2); @@ -543,6 +541,7 @@ export function applyTheme(theme) { if (theme.buttonBg) { changeCss('.btn.btn-primary', 'background:' + theme.buttonBg, 1); changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.25), 1); + changeCss('.file-playback-controls', 'color:' + changeColor(theme.buttonBg, -0.25), 1); } if (theme.buttonColor) { @@ -871,7 +870,7 @@ export function getFileUrl(filename) { if (url.indexOf('/api/v1/files/get') !== -1) { url = filename.split('/api/v1/files/get')[1]; } - url = getWindowLocationOrigin() + '/api/v1/files/get' + url; + url = getWindowLocationOrigin() + '/api/v1/files/get' + url + '?' + getSessionIndex(); return url; } @@ -882,6 +881,14 @@ export function getFileName(path) { return split[split.length - 1]; } +export function getSessionIndex() { + if (global.window.mm_session_token_index >= 0) { + return 'session_token_index=' + global.window.mm_session_token_index; + } + + return ''; +} + // Generates a RFC-4122 version 4 compliant globally unique identifier. export function generateId() { // implementation taken from http://stackoverflow.com/a/2117523 @@ -967,3 +974,11 @@ export function getShortenedTeamURL() { } return teamURL + '/'; } + +export function windowWidth() { + return $(window).width(); +} + +export function windowHeight() { + return $(window).height(); +} |