diff options
Diffstat (limited to 'web/react/components')
27 files changed, 1400 insertions, 959 deletions
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index d309ced2e..8e0ab0555 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -6,6 +6,7 @@ var AdminStore = require('../../stores/admin_store.jsx'); var TeamStore = require('../../stores/team_store.jsx'); var AsyncClient = require('../../utils/async_client.jsx'); var LoadingScreen = require('../loading_screen.jsx'); +var Utils = require('../../utils/utils.jsx'); var EmailSettingsTab = require('./email_settings.jsx'); var LogSettingsTab = require('./log_settings.jsx'); @@ -46,7 +47,8 @@ export default class AdminController extends React.Component { }; if (!props.tab) { - history.replaceState(null, null, `/admin_console/${this.state.selected}`); + var tokenIndex = Utils.getUrlParameter('session_token_index'); + history.replaceState(null, null, `/admin_console/${this.state.selected}?session_token_index=${tokenIndex}`); } } diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index f2fb1c96d..0d52ae347 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -3,6 +3,7 @@ var AdminSidebarHeader = require('./admin_sidebar_header.jsx'); var SelectTeamModal = require('./select_team_modal.jsx'); +var Utils = require('../../utils/utils.jsx'); export default class AdminSidebar extends React.Component { constructor(props) { @@ -24,12 +25,13 @@ export default class AdminSidebar extends React.Component { handleClick(name, teamId, e) { e.preventDefault(); this.props.selectTab(name, teamId); - history.pushState({name, teamId}, null, `/admin_console/${name}/${teamId || ''}`); + var tokenIndex = Utils.getUrlParameter('session_token_index'); + history.pushState({name, teamId}, null, `/admin_console/${name}/${teamId || ''}?session_token_index=${tokenIndex}`); } isSelected(name, teamId) { if (this.props.selected === name) { - if (name === 'team_users') { + if (name === 'team_users' || name === 'team_analytics') { if (this.props.selectedTeam != null && this.props.selectedTeam === teamId) { return 'active'; } diff --git a/web/react/components/center_panel.jsx b/web/react/components/center_panel.jsx new file mode 100644 index 000000000..242c2c637 --- /dev/null +++ b/web/react/components/center_panel.jsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const TutorialIntroScreens = require('./tutorial/tutorial_intro_screens.jsx'); +const CreatePost = require('./create_post.jsx'); +const PostsViewContainer = require('./posts_view_container.jsx'); +const ChannelHeader = require('./channel_header.jsx'); +const Navbar = require('./navbar.jsx'); +const FileUploadOverlay = require('./file_upload_overlay.jsx'); + +const PreferenceStore = require('../stores/preference_store.jsx'); +const UserStore = require('../stores/user_store.jsx'); + +const Constants = require('../utils/constants.jsx'); +const TutorialSteps = Constants.TutorialSteps; +const Preferences = Constants.Preferences; + +export default class CenterPanel extends React.Component { + constructor(props) { + super(props); + + this.onPreferenceChange = this.onPreferenceChange.bind(this); + + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + this.state = {showTutorialScreens: parseInt(tutorialPref.value, 10) === TutorialSteps.INTRO_SCREENS}; + } + componentDidMount() { + PreferenceStore.addChangeListener(this.onPreferenceChange); + } + componentWillUnmount() { + PreferenceStore.removeChangeListener(this.onPreferenceChange); + } + onPreferenceChange() { + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + this.setState({showTutorialScreens: parseInt(tutorialPref.value, 10) <= TutorialSteps.INTRO_SCREENS}); + } + render() { + let postsContainer; + if (this.state.showTutorialScreens) { + postsContainer = <TutorialIntroScreens />; + } else { + postsContainer = <PostsViewContainer />; + } + + return ( + <div className='inner__wrap channel__wrap'> + <div className='row header'> + <div id='navbar'> + <Navbar/> + </div> + </div> + <div className='row main'> + <FileUploadOverlay + id='file_upload_overlay' + overlayType='center' + /> + <div + id='app-content' + className='app__content' + > + <div id='channel-header'> + <ChannelHeader /> + </div> + <div id='post-list'> + {postsContainer} + </div> + <div + className='post-create__container' + id='post-create' + > + <CreatePost /> + </div> + </div> + </div> + </div> + ); + } +} + +CenterPanel.defaultProps = { +}; + +CenterPanel.propTypes = { +}; diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 101fd85e5..20f106f30 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -4,6 +4,7 @@ const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const SearchStore = require('../stores/search_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); const NavbarSearchBox = require('./search_bar.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); @@ -46,12 +47,14 @@ export default class ChannelHeader extends React.Component { ChannelStore.addExtraInfoChangeListener(this.onListenerChange); SearchStore.addSearchChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); + PreferenceStore.addChangeListener(this.onListenerChange); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); SearchStore.removeSearchChangeListener(this.onListenerChange); - UserStore.addChangeListener(this.onListenerChange); + UserStore.removeChangeListener(this.onListenerChange); + PreferenceStore.removeChangeListener(this.onListenerChange); } onListenerChange() { const newState = this.getStateFromStores(); @@ -134,7 +137,7 @@ export default class ChannelHeader extends React.Component { } else { contact = this.state.users[0]; } - channelTitle = contact.nickname || contact.username; + channelTitle = Utils.displayUsername(contact.id); } } diff --git a/web/react/components/channel_view.jsx b/web/react/components/channel_view.jsx new file mode 100644 index 000000000..3f53a94c2 --- /dev/null +++ b/web/react/components/channel_view.jsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const CenterPanel = require('../components/center_panel.jsx'); +const Sidebar = require('../components/sidebar.jsx'); +const SidebarRight = require('../components/sidebar_right.jsx'); +const SidebarRightMenu = require('../components/sidebar_right_menu.jsx'); + +export default class ChannelView extends React.Component { + constructor(props) { + super(props); + } + render() { + return ( + <div className='container-fluid'> + <div + className='sidebar--right' + id='sidebar-right' + > + <SidebarRight/> + </div> + <div + className='sidebar--menu' + id='sidebar-menu' + > + <SidebarRightMenu/> + </div> + <div + className='sidebar--left' + id='sidebar-left' + > + <Sidebar/> + </div> + <CenterPanel /> + </div> + ); + } +} +ChannelView.defaultProps = { +}; + +ChannelView.propTypes = { +}; diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index cdbc3bc6d..1545cdfaa 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -1,21 +1,26 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +const MsgTyping = require('./msg_typing.jsx'); +const Textbox = require('./textbox.jsx'); +const FileUpload = require('./file_upload.jsx'); +const FilePreview = require('./file_preview.jsx'); +const TutorialTip = require('./tutorial/tutorial_tip.jsx'); + const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); const Client = require('../utils/client.jsx'); const AsyncClient = require('../utils/async_client.jsx'); +const Utils = require('../utils/utils.jsx'); + const ChannelStore = require('../stores/channel_store.jsx'); const PostStore = require('../stores/post_store.jsx'); const UserStore = require('../stores/user_store.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); const PreferenceStore = require('../stores/preference_store.jsx'); -const MsgTyping = require('./msg_typing.jsx'); -const Textbox = require('./textbox.jsx'); -const FileUpload = require('./file_upload.jsx'); -const FilePreview = require('./file_preview.jsx'); -const Utils = require('../utils/utils.jsx'); +const SocketStore = require('../stores/socket_store.jsx'); const Constants = require('../utils/constants.jsx'); +const Preferences = Constants.Preferences; +const TutorialSteps = Constants.TutorialSteps; const ActionTypes = Constants.ActionTypes; const KeyCodes = Constants.KeyCodes; @@ -36,15 +41,16 @@ export default class CreatePost extends React.Component { this.handleTextDrop = this.handleTextDrop.bind(this); this.removePreview = this.removePreview.bind(this); this.onChange = this.onChange.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleResize = this.handleResize.bind(this); this.sendMessage = this.sendMessage.bind(this); - this.onPreferenceChange = this.onPreferenceChange.bind(this); PostStore.clearDraftUploads(); const draft = this.getCurrentDraft(); + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); this.state = { channelId: ChannelStore.getCurrentId(), @@ -55,16 +61,12 @@ export default class CreatePost extends React.Component { initialText: draft.messageText, windowWidth: Utils.windowWidth(), windowHeight: Utils.windowHeight(), - ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value, + showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.POST_POPOVER }; PreferenceStore.addChangeListener(this.onPreferenceChange); } - onPreferenceChange() { - this.setState({ - ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value - }); - } handleResize() { this.setState({ windowWidth: Utils.windowWidth(), @@ -176,6 +178,7 @@ export default class CreatePost extends React.Component { PostStore.storePendingPost(post); PostStore.storeDraft(channel.id, null); + PostStore.jumpPostsViewToBottom(); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); Client.createPost(post, channel, @@ -317,11 +320,13 @@ export default class CreatePost extends React.Component { } componentDidMount() { ChannelStore.addChangeListener(this.onChange); + PreferenceStore.addChangeListener(this.onPreferenceChange); this.resizePostHolder(); window.addEventListener('resize', this.handleResize); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onChange); + PreferenceStore.removeChangeListener(this.onPreferenceChange); window.removeEventListener('resize', this.handleResize); } onChange() { @@ -332,6 +337,13 @@ export default class CreatePost extends React.Component { this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress}); } } + onPreferenceChange() { + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + this.setState({ + showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.POST_POPOVER, + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); + } getFileCount(channelId) { if (channelId === this.state.channelId) { return this.state.previews.length + this.state.uploadsInProgress.length; @@ -366,6 +378,25 @@ export default class CreatePost extends React.Component { }); } } + createTutorialTip() { + const screens = []; + + screens.push( + <div> + <h4>{'Sending Messages'}</h4> + <p>{'Type here to write a message.'}</p> + <p>{'Click the attachment button to upload an image or a file.'}</p> + </div> + ); + + return ( + <TutorialTip + placement='top' + screens={screens} + overlayClass='tip-overlay--chat' + /> + ); + } render() { let serverError = null; if (this.state.serverError) { @@ -397,6 +428,11 @@ export default class CreatePost extends React.Component { postFooterClassName += ' has-error'; } + let tutorialTip = null; + if (this.state.showTutorialTip) { + tutorialTip = this.createTutorialTip(); + } + return ( <form id='create_post' @@ -435,6 +471,7 @@ export default class CreatePost extends React.Component { > <i className='fa fa-paper-plane' /> </a> + {tutorialTip} </div> <div className={postFooterClassName}> {postError} diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index 2abb3f151..ef32baa7d 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -120,7 +120,7 @@ export default class EditPostModal extends React.Component { PreferenceStore.addChangeListener(this.onPreferenceChange); } componentWillUnmount() { - PostStore.removeEditPostListener(this.handleEditPostEvent); + PostStore.removeEditPostListner(this.handleEditPostEvent); PreferenceStore.removeChangeListener(this.onPreferenceChange); } render() { diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index dc21fad21..f43bdffdf 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -104,7 +104,7 @@ export default class NavbarDropdown extends React.Component { </li> ); - if (this.props.teamType === 'O') { + if (this.props.teamType === Constants.OPEN_TEAM) { teamLink = ( <li> <a diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index dedac8951..c3c5b3e0b 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -204,7 +204,6 @@ export default class Post extends React.Component { posts={posts} handleCommentClick={this.handleCommentClick} retryPost={this.retryPost} - resize={this.props.resize} /> <PostInfo ref='info' @@ -228,6 +227,5 @@ Post.propTypes = { sameUser: React.PropTypes.bool, sameRoot: React.PropTypes.bool, hideProfilePic: React.PropTypes.bool, - isLastComment: React.PropTypes.bool, - resize: React.PropTypes.func + isLastComment: React.PropTypes.bool }; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 7138e2cb4..e1f495d54 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -50,7 +50,6 @@ export default class PostBody extends React.Component { componentDidUpdate() { this.parseEmojis(); - this.props.resize(); } componentWillReceiveProps(nextProps) { @@ -338,6 +337,5 @@ PostBody.propTypes = { post: React.PropTypes.object.isRequired, parentPost: React.PropTypes.object, retryPost: React.PropTypes.func.isRequired, - handleCommentClick: React.PropTypes.func.isRequired, - resize: React.PropTypes.func.isRequired + handleCommentClick: React.PropTypes.func.isRequired }; diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index ddda48e06..a01d842e5 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -3,10 +3,9 @@ var UserStore = require('../stores/user_store.jsx'); var utils = require('../utils/utils.jsx'); +var TimeSince = require('./time_since.jsx'); var Constants = require('../utils/constants.jsx'); -var Tooltip = ReactBootstrap.Tooltip; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; export default class PostInfo extends React.Component { constructor(props) { @@ -144,21 +143,12 @@ export default class PostInfo extends React.Component { var dropdown = this.createDropdown(); - let tooltip = <Tooltip id={post.id + 'tooltip'}>{`${utils.displayDate(post.create_at)} at ${utils.displayTime(post.create_at)}`}</Tooltip>; - return ( <ul className='post-header post-info'> <li className='post-header-col'> - <OverlayTrigger - delayShow={500} - container={this} - placement='top' - overlay={tooltip} - > - <time className='post-profile-time'> - {utils.displayDateTime(post.create_at)} - </time> - </OverlayTrigger> + <TimeSince + eventTime={post.create_at} + /> </li> <li className='post-header-col post-header__reply'> <div className='dropdown'> diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx deleted file mode 100644 index 444736db5..000000000 --- a/web/react/components/post_list.jsx +++ /dev/null @@ -1,764 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -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 TeamStore = require('../stores/team_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) { - super(props); - - this.gotMorePosts = false; - this.scrolled = false; - this.prevScrollTop = 0; - this.seenNewMessages = false; - this.isUserScroll = true; - this.userHasSeenNew = false; - this.loadInProgress = false; - - this.onChange = this.onChange.bind(this); - this.onTimeChange = this.onTimeChange.bind(this); - this.onSocketChange = this.onSocketChange.bind(this); - this.createChannelIntroMessage = this.createChannelIntroMessage.bind(this); - this.loadMorePosts = this.loadMorePosts.bind(this); - this.loadFirstPosts = this.loadFirstPosts.bind(this); - this.activate = this.activate.bind(this); - this.deactivate = this.deactivate.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; - } - getStateFromStores(id) { - var postList = PostStore.getPosts(id); - - if (postList != null) { - var deletedPosts = PostStore.getUnseenDeletedPosts(id); - - if (deletedPosts && Object.keys(deletedPosts).length > 0) { - for (var pid in deletedPosts) { - if (deletedPosts.hasOwnProperty(pid)) { - postList.posts[pid] = deletedPosts[pid]; - postList.order.unshift(pid); - } - } - - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { - return -1; - } - if (postList.posts[a].create_at < postList.posts[b].create_at) { - return 1; - } - return 0; - }); - } - - var pendingPostList = PostStore.getPendingPosts(id); - - if (pendingPostList) { - postList.order = pendingPostList.order.concat(postList.order); - for (var ppid in pendingPostList.posts) { - if (pendingPostList.posts.hasOwnProperty(ppid)) { - postList.posts[ppid] = pendingPostList.posts[ppid]; - } - } - } - } - - return { - postList - }; - } - componentDidMount() { - window.onload = () => this.scrollToBottom(); - if (this.props.isActive) { - this.activate(); - this.loadFirstPosts(this.props.channelId); - } - } - componentWillUnmount() { - this.deactivate(); - } - activate() { - this.gotMorePosts = false; - this.scrolled = false; - this.prevScrollTop = 0; - this.seenNewMessages = false; - this.isUserScroll = true; - this.userHasSeenNew = false; - - PostStore.clearUnseenDeletedPosts(this.props.channelId); - PostStore.addChangeListener(this.onChange); - UserStore.addStatusesChangeListener(this.onTimeChange); - PreferenceStore.addChangeListener(this.onTimeChange); - SocketStore.addChangeListener(this.onSocketChange); - - const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - - window.addEventListener('resize', this.handleResize); - - postHolder.on('scroll', () => { - const position = postHolder.scrollTop() + postHolder.height() + 14; - const bottom = postHolder[0].scrollHeight; - - if (position >= bottom) { - this.scrolled = false; - } else { - this.scrolled = true; - } - - if (this.isUserScroll) { - this.userHasSeenNew = true; - } - this.isUserScroll = true; - - $('.top-visible-post').removeClass('top-visible-post'); - - $(ReactDOM.findDOMNode(this.refs.postlistcontent)).children().each(function select() { - if ($(this).position().top + $(this).height() / 2 > 0) { - $(this).addClass('top-visible-post'); - return false; - } - }); - }); - - $('.post-list__content div .post').removeClass('post--last'); - $('.post-list__content div:last-child .post').addClass('post--last'); - - if (!this.state.isFirstLoadComplete) { - this.loadFirstPosts(this.props.channelId); - } - - this.resizePostList(); - this.onChange(); - this.scrollToBottom(); - } - deactivate() { - PostStore.removeChangeListener(this.onChange); - UserStore.removeStatusesChangeListener(this.onTimeChange); - SocketStore.removeChangeListener(this.onSocketChange); - PreferenceStore.removeChangeListener(this.onTimeChange); - $('body').off('click.userpopover'); - - window.removeEventListener('resize', this.handleResize); - - var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - postHolder.off('scroll'); - } - componentDidUpdate(prevProps, prevState) { - if (!this.props.isActive) { - 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'); - - if (this.state.postList == null || prevState.postList == null) { - this.scrollToBottom(); - return; - } - - var order = this.state.postList.order || []; - var posts = this.state.postList.posts || {}; - var oldOrder = prevState.postList.order || []; - var oldPosts = prevState.postList.posts || {}; - var userId = UserStore.getCurrentId(); - var firstPost = posts[order[0]] || {}; - var isNewPost = oldOrder.indexOf(order[0]) === -1; - - if (this.props.isActive && !prevProps.isActive) { - this.scrollToBottom(); - } else if (oldOrder.length === 0) { - this.scrollToBottom(); - - // the user is scrolled to the bottom - } else if (!this.scrolled) { - this.scrollToBottom(); - - // there's a new post and - // it's by the user (and not from their webhook) and not a comment - } else if (isNewPost && - userId === firstPost.user_id && - !firstPost.props.from_webhook && - !Utils.isComment(firstPost)) { - this.scrollToBottom(true); - - // the user clicked 'load more messages' - } else if (this.gotMorePosts && oldOrder.length > 0) { - let index; - if (prevState.numToDisplay >= oldOrder.length) { - index = oldOrder.length - 1; - } else { - index = prevState.numToDisplay; - } - const lastPost = oldPosts[oldOrder[index]]; - $('#post_' + lastPost.id)[0].scrollIntoView(); - this.gotMorePosts = false; - } else { - this.scrollTo(this.prevScrollTop); - } - } - componentWillUpdate() { - var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - this.prevScrollTop = postHolder.scrollTop(); - } - componentWillReceiveProps(nextProps) { - if (nextProps.isActive === true && this.props.isActive === false) { - this.activate(); - } else if (nextProps.isActive === false && this.props.isActive === true) { - this.deactivate(); - } - } - 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 = this.state.windowHeight - $('#create_post').height() - $('#error_bar').outerHeight() - 50; - postHolder.css('height', height + 'px'); - } - } - scrollTo(val) { - this.isUserScroll = false; - var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - postHolder[0].scrollTop = val; - } - scrollToBottom(force) { - this.isUserScroll = false; - var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); - if ($('#new_message_' + this.props.channelId)[0] && !this.userHasSeenNew && !force) { - $('#new_message_' + this.props.channelId)[0].scrollIntoView(); - } else { - postHolder.addClass('hide-scroll'); - postHolder[0].scrollTop = postHolder[0].scrollHeight; - postHolder.removeClass('hide-scroll'); - } - } - loadFirstPosts(id) { - if (this.loadInProgress) { - return; - } - - if (this.props.channelId == null) { - return; - } - - this.loadInProgress = true; - Client.getPosts( - id, - PostStore.getLatestUpdate(id), - () => { - this.loadInProgress = false; - this.setState({isFirstLoadComplete: true}); - }, - () => { - this.loadInProgress = false; - this.setState({isFirstLoadComplete: true}); - } - ); - } - onChange() { - var newState = this.getStateFromStores(this.props.channelId); - - if (!Utils.areStatesEqual(newState.postList, this.state.postList)) { - this.setState(newState); - } - } - onSocketChange(msg) { - 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; - } - - if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() !== msg.user_id) { - $('#post_deleted').modal('show'); - } - } - } - onTimeChange() { - if (!this.state.postList) { - return; - } - - for (var id in this.state.postList.posts) { - if (!this.refs[id]) { - continue; - } - this.refs[id].forceUpdateInfo(); - } - } - createDMIntroMessage(channel) { - var teammate = Utils.getDirectTeammate(channel.id); - - if (teammate) { - var teammateName = teammate.username; - if (teammate.nickname.length > 0) { - teammateName = teammate.nickname; - } - - return ( - <div className='channel-intro'> - <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 + '&' + Utils.getSessionIndex()} - height='50' - width='50' - /> - </div> - <div className='channel-intro-profile'> - <strong><UserProfile userId={teammate.id} /></strong> - </div> - <p className='channel-intro-text'> - {'This is the start of your direct message history with ' + teammateName + '.'}<br/> - {'Direct messages and files shared here are not shown to people outside this area.'} - </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - </div> - ); - } - - return ( - <div className='channel-intro'> - <p className='channel-intro-text'>{'This is the start of your direct message history with this teammate. Direct messages and files shared here are not shown to people outside this area.'}</p> - </div> - ); - } - createChannelIntroMessage(channel) { - if (channel.type === 'D') { - return this.createDMIntroMessage(channel); - } else if (ChannelStore.isDefault(channel)) { - return this.createDefaultIntroMessage(channel); - } else if (channel.name === Constants.OFFTOPIC_CHANNEL) { - return this.createOffTopicIntroMessage(channel); - } else if (channel.type === 'O' || channel.type === 'P') { - return this.createStandardIntroMessage(channel); - } - } - createDefaultIntroMessage(channel) { - const team = TeamStore.getCurrent(); - let inviteModalLink; - if (team.type === Constants.INVITE_TEAM) { - inviteModalLink = ( - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#invite_member' - > - <i className='fa fa-user-plus'></i>{'Invite others to this team'} - </a> - ); - } else { - inviteModalLink = ( - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} - > - <i className='fa fa-user-plus'></i>{'Invite others to this team'} - </a> - ); - } - - return ( - <div className='channel-intro'> - <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> - <p className='channel-intro__content'> - <strong>{'Welcome to ' + channel.display_name + '!'}</strong> - <br/><br/> - {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} - </p> - {inviteModalLink} - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - <br/> - </div> - ); - } - createOffTopicIntroMessage(channel) { - return ( - <div className='channel-intro'> - <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/> - </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#channel_invite' - > - <i className='fa fa-user-plus'></i>{'Invite others to this channel'} - </a> - </div> - ); - } - getChannelCreator(channel) { - if (channel.creator_id.length > 0) { - var creator = UserStore.getProfile(channel.creator_id); - if (creator) { - return creator.username; - } - } - - var members = ChannelStore.getExtraInfo(channel.id).members; - for (var i = 0; i < members.length; i++) { - if (Utils.isAdmin(members[i].roles)) { - return members[i].username; - } - } - } - createStandardIntroMessage(channel) { - var uiName = channel.display_name; - var creatorName = ''; - - var uiType; - var memberMessage; - if (channel.type === 'P') { - uiType = 'private group'; - memberMessage = ' Only invited members can see this private group.'; - } else { - uiType = 'channel'; - memberMessage = ' Any member can join and read this channel.'; - } - - var createMessage; - if (creatorName === '') { - 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>); - } - - return ( - <div className='channel-intro'> - <h4 className='channel-intro__title'>{'Beginning of ' + uiName}</h4> - <p className='channel-intro__content'> - {createMessage} - {memberMessage} - <br/> - </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#channel_invite' - > - <i className='fa fa-user-plus'></i>{'Invite others to this ' + uiType} - </a> - </div> - ); - } - createPosts(posts, order) { - var postCtls = []; - var previousPostDay = new Date(0); - var userId = UserStore.getCurrentId(); - - var renderedLastViewed = false; - var lastViewed = Number.MAX_VALUE; - - if (ChannelStore.getMember(this.props.channelId) != null) { - lastViewed = ChannelStore.getMember(this.props.channelId).last_viewed_at; - } - - var numToDisplay = this.state.numToDisplay; - if (order.length - 1 < numToDisplay) { - numToDisplay = order.length - 1; - } - - for (var i = numToDisplay; i >= 0; i--) { - var post = posts[order[i]]; - var parentPost = posts[post.parent_id]; - - // If the post is a comment whose parent has been deleted, don't add it to the list. - if (parentPost && parentPost.state === Constants.POST_DELETED) { - continue; - } - - var sameUser = false; - var sameRoot = false; - var hideProfilePic = false; - var prevPost = posts[order[i + 1]]; - - 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); - - // hide the profile pic if: - // the previous post was made by the same user as the current post, - // the previous post is not a comment, - // the current post is not a comment, - // 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) && - (!post.props || !post.props.from_webhook) && - (!prevPost.props || !prevPost.props.from_webhook)) { - hideProfilePic = true; - } - } - - // 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 postCtl = ( - <Post - key={post.id + 'postKey'} - ref={post.id} - sameUser={sameUser} - sameRoot={sameRoot} - post={post} - parentPost={parentPost} - posts={posts} - hideProfilePic={hideProfilePic} - isLastComment={isLastComment} - resize={this.updateScroll} - /> - ); - - const currentPostDay = Utils.getDateForUnixTicks(post.create_at); - if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { - postCtls.push( - <div - key={currentPostDay.toDateString()} - className='date-separator' - > - <hr className='separator__hr' /> - <div className='separator__text'>{currentPostDay.toDateString()}</div> - </div> - ); - } - - if (post.user_id !== userId && post.create_at > lastViewed && !renderedLastViewed) { - renderedLastViewed = true; - - // Temporary fix to solve ie11 rendering issue - let newSeparatorId = ''; - if (!Utils.isBrowserIE()) { - newSeparatorId = 'new_message_' + this.props.channelId; - } - postCtls.push( - <div - id={newSeparatorId} - key='unviewed' - className='new-separator' - > - <hr - className='separator__hr' - /> - <div className='separator__text'>{'New Messages'}</div> - </div> - ); - } - postCtls.push(postCtl); - previousPostDay = currentPostDay; - } - - return postCtls; - } - loadMorePosts() { - if (this.state.postList == null) { - return; - } - - var posts = this.state.postList.posts; - var order = this.state.postList.order; - var channelId = this.props.channelId; - - $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Retrieving more messages...'); - - Client.getPostsPage( - channelId, - order.length, - Constants.POST_CHUNK_SIZE, - function success(data) { - $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Load more messages'); - this.gotMorePosts = true; - this.setState({numToDisplay: this.state.numToDisplay + Constants.POST_CHUNK_SIZE}); - - if (!data) { - return; - } - - if (data.order.length === 0) { - return; - } - - var postList = {}; - postList.posts = $.extend(posts, data.posts); - postList.order = order.concat(data.order); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POSTS, - id: channelId, - post_list: postList - }); - - Client.getProfiles(); - }.bind(this), - function fail(err) { - $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Load more messages'); - AsyncClient.dispatchError(err, 'getPosts'); - }.bind(this) - ); - } - render() { - var order = []; - var posts; - var channel = ChannelStore.get(this.props.channelId); - - if (this.state.postList != null) { - posts = this.state.postList.posts; - order = this.state.postList.order; - } - - var moreMessages = <p className='beginning-messages-text'>{'Beginning of Channel'}</p>; - if (channel != null) { - if (order.length >= this.state.numToDisplay) { - moreMessages = ( - <a - ref='loadmore' - className='more-messages-text theme' - href='#' - onClick={this.loadMorePosts} - > - {'Load more messages'} - </a> - ); - } else { - moreMessages = this.createChannelIntroMessage(channel); - } - } - - var postCtls = []; - if (posts && this.state.isFirstLoadComplete) { - postCtls = this.createPosts(posts, order); - } else { - postCtls.push( - <LoadingScreen - position='absolute' - key='loading' - />); - } - - var activeClass = ''; - if (!this.props.isActive) { - activeClass = 'inactive'; - } - - return ( - <div - ref='postlist' - className={'post-list-holder-by-time ' + activeClass} - > - <div className='post-list__table'> - <div - ref='postlistcontent' - className='post-list__content' - > - {moreMessages} - {postCtls} - </div> - </div> - </div> - ); - } -} - -PostList.defaultProps = { - isActive: false, - channelId: null -}; -PostList.propTypes = { - isActive: React.PropTypes.bool, - channelId: React.PropTypes.string -}; diff --git a/web/react/components/post_list_container.jsx b/web/react/components/post_list_container.jsx deleted file mode 100644 index 09cee6218..000000000 --- a/web/react/components/post_list_container.jsx +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -const PostList = require('./post_list.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); - -export default class PostListContainer extends React.Component { - constructor() { - super(); - - this.onChange = this.onChange.bind(this); - this.onLeave = this.onLeave.bind(this); - - let currentChannelId = ChannelStore.getCurrentId(); - if (currentChannelId) { - this.state = {currentChannelId: currentChannelId, postLists: [currentChannelId]}; - } else { - this.state = {currentChannelId: null, postLists: []}; - } - } - componentDidMount() { - ChannelStore.addChangeListener(this.onChange); - ChannelStore.addLeaveListener(this.onLeave); - } - onChange() { - let channelId = ChannelStore.getCurrentId(); - if (channelId === this.state.currentChannelId) { - return; - } - - let postLists = this.state.postLists; - if (postLists.indexOf(channelId) === -1) { - postLists.push(channelId); - } - this.setState({currentChannelId: channelId, postLists: postLists}); - } - onLeave(id) { - let postLists = this.state.postLists; - var index = postLists.indexOf(id); - if (index !== -1) { - postLists.splice(index, 1); - } - } - render() { - let postLists = this.state.postLists; - let channelId = this.state.currentChannelId; - - let postListCtls = []; - for (let i = 0; i <= this.state.postLists.length - 1; i++) { - postListCtls.push( - <PostList - key={'postlistkey' + i} - channelId={postLists[i]} - isActive={postLists[i] === channelId} - /> - ); - } - - return ( - <div>{postListCtls}</div> - ); - } -} diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx new file mode 100644 index 000000000..2b81d1d79 --- /dev/null +++ b/web/react/components/posts_view.jsx @@ -0,0 +1,303 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); +const Post = require('./post.jsx'); +const Constants = require('../utils/constants.jsx'); + +export default class PostsView extends React.Component { + constructor(props) { + super(props); + + this.handleScroll = this.handleScroll.bind(this); + this.isAtBottom = this.isAtBottom.bind(this); + this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.createPosts = this.createPosts.bind(this); + this.updateScrolling = this.updateScrolling.bind(this); + this.handleResize = this.handleResize.bind(this); + + this.jumpToPostNode = null; + this.wasAtBottom = true; + this.scrollHeight = 0; + } + static get SCROLL_TYPE_FREE() { + return 1; + } + static get SCROLL_TYPE_BOTTOM() { + return 2; + } + static get SIDEBAR_OPEN() { + return 3; + } + isAtBottom() { + return ((this.refs.postlist.scrollHeight - this.refs.postlist.scrollTop) === this.refs.postlist.clientHeight); + } + handleScroll() { + // HACK FOR RHS -- REMOVE WHEN RHS DIES + const childNodes = this.refs.postlistcontent.childNodes; + for (let i = 0; i < childNodes.length; i++) { + // If the node is 1/3 down the page + if (childNodes[i].offsetTop > (this.refs.postlist.scrollTop + (this.refs.postlist.offsetHeight / 3))) { + this.jumpToPostNode = childNodes[i]; + break; + } + } + this.wasAtBottom = this.isAtBottom(); + + // --- -------- + + this.props.postViewScrolled(this.isAtBottom()); + this.prevScrollHeight = this.refs.postlist.scrollHeight; + } + loadMorePostsTop() { + this.props.loadMorePostsTopClicked(); + } + createPosts(posts, order) { + const postCtls = []; + let previousPostDay = new Date(0); + const userId = UserStore.getCurrentId(); + + let renderedLastViewed = false; + + let numToDisplay = this.props.numPostsToDisplay; + if (order.length - 1 < numToDisplay) { + numToDisplay = order.length - 1; + } + + for (let i = numToDisplay; i >= 0; i--) { + const post = posts[order[i]]; + const parentPost = posts[post.parent_id]; + const prevPost = posts[order[i + 1]]; + + // If the post is a comment whose parent has been deleted, don't add it to the list. + if (parentPost && parentPost.state === Constants.POST_DELETED) { + continue; + } + + let sameUser = false; + let sameRoot = false; + let hideProfilePic = false; + + 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); + + // hide the profile pic if: + // the previous post was made by the same user as the current post, + // the previous post is not a comment, + // the current post is not a comment, + // 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) && + (!post.props || !post.props.from_webhook) && + (!prevPost.props || !prevPost.props.from_webhook)) { + hideProfilePic = true; + } + } + + // 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 postCtl = ( + <Post + key={post.id + 'postKey'} + ref={post.id} + sameUser={sameUser} + sameRoot={sameRoot} + post={post} + parentPost={parentPost} + posts={posts} + hideProfilePic={hideProfilePic} + isLastComment={isLastComment} + /> + ); + + const currentPostDay = Utils.getDateForUnixTicks(post.create_at); + if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { + postCtls.push( + <div + key={currentPostDay.toDateString()} + className='date-separator' + > + <hr className='separator__hr' /> + <div className='separator__text'>{currentPostDay.toDateString()}</div> + </div> + ); + } + + if (post.user_id !== userId && + this.props.messageSeparatorTime !== 0 && + post.create_at > this.props.messageSeparatorTime && + !renderedLastViewed) { + renderedLastViewed = true; + + // Temporary fix to solve ie11 rendering issue + let newSeparatorId = ''; + if (!Utils.isBrowserIE()) { + newSeparatorId = 'new_message_' + post.id; + } + postCtls.push( + <div + id={newSeparatorId} + key='unviewed' + className='new-separator' + > + <hr + className='separator__hr' + /> + <div className='separator__text'>{'New Messages'}</div> + </div> + ); + } + postCtls.push(postCtl); + previousPostDay = currentPostDay; + } + + return postCtls; + } + updateScrolling() { + if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) { + window.requestAnimationFrame(() => { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + }); + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPost) { + window.requestAnimationFrame(() => { + const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPost]); + postNode.scrollIntoView(); + if (this.refs.postlist.scrollTop === postNode.offsetTop) { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - postNode.offsetTop); + } + }); + } else if (this.props.scrollType === PostsView.SIDEBAR_OPEN) { + // If we are at the bottom then stay there + if (this.wasAtBottom) { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + } else { + window.requestAnimationFrame(() => { + this.jumpToPostNode.scrollIntoView(); + if (this.refs.postlist.scrollTop === this.jumpToPostNode.offsetTop) { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - this.jumpToPostNode.offsetTop); + } + }); + } + } else if (this.refs.postlist.scrollHeight !== this.prevScrollHeight) { + window.requestAnimationFrame(() => { + this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + }); + } + } + handleResize() { + this.updateScrolling(); + } + componentDidMount() { + this.updateScrolling(); + window.addEventListener('resize', this.handleResize); + } + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + } + componentDidUpdate() { + this.updateScrolling(); + } + shouldComponentUpdate(nextProps) { + if (this.props.isActive !== nextProps.isActive) { + return true; + } + if (this.props.postList !== nextProps.postList) { + return true; + } + if (this.props.scrollPost !== nextProps.scrollPost) { + return true; + } + if (this.props.scrollType !== nextProps.scrollType && nextProps.scrollType !== PostsView.SCROLL_TYPE_FREE) { + return true; + } + if (this.props.numPostsToDisplay !== nextProps.numPostsToDisplay) { + return true; + } + if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { + return true; + } + if (!Utils.areStatesEqual(this.props.postList, nextProps.postList)) { + return true; + } + + return false; + } + render() { + let posts = []; + let order = []; + let moreMessages; + let postElements; + let activeClass = 'inactive'; + if (this.props.postList != null) { + posts = this.props.postList.posts; + order = this.props.postList.order; + + // Create intro message or top loadmore link + if (order.length >= this.props.numPostsToDisplay) { + moreMessages = ( + <a + ref='loadmore' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsTop} + > + {'Load more messages'} + </a> + ); + } else { + moreMessages = this.props.introText; + } + + // Create post elements + postElements = this.createPosts(posts, order); + + // Show ourselves if we are marked active + if (this.props.isActive) { + activeClass = ''; + } + } + + return ( + <div + ref='postlist' + className={'post-list-holder-by-time ' + activeClass} + onScroll={this.handleScroll} + > + <div className='post-list__table'> + <div + ref='postlistcontent' + className='post-list__content' + > + {moreMessages} + {postElements} + </div> + </div> + </div> + ); + } +} +PostsView.defaultProps = { +}; + +PostsView.propTypes = { + isActive: React.PropTypes.bool, + postList: React.PropTypes.object, + scrollPost: React.PropTypes.string, + scrollType: React.PropTypes.number, + postViewScrolled: React.PropTypes.func.isRequired, + loadMorePostsTopClicked: React.PropTypes.func.isRequired, + numPostsToDisplay: React.PropTypes.number, + introText: React.PropTypes.element, + messageSeparatorTime: React.PropTypes.number +}; diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx new file mode 100644 index 000000000..7671ca01d --- /dev/null +++ b/web/react/components/posts_view_container.jsx @@ -0,0 +1,267 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostsView = require('./posts_view.jsx'); +const LoadingScreen = require('./loading_screen.jsx'); + +const ChannelStore = require('../stores/channel_store.jsx'); +const PostStore = require('../stores/post_store.jsx'); + +const Utils = require('../utils/utils.jsx'); +const Client = require('../utils/client.jsx'); +const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); + +const Constants = require('../utils/constants.jsx'); +const ActionTypes = Constants.ActionTypes; + +import {createChannelIntroMessage} from '../utils/channel_intro_mssages.jsx'; + +export default class PostsViewContainer extends React.Component { + constructor() { + super(); + + this.onChannelChange = this.onChannelChange.bind(this); + this.onChannelLeave = this.onChannelLeave.bind(this); + this.onPostsChange = this.onPostsChange.bind(this); + this.handlePostsViewScroll = this.handlePostsViewScroll.bind(this); + this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.postsLoaded = this.postsLoaded.bind(this); + this.postsLoadedFailure = this.postsLoadedFailure.bind(this); + this.handlePostsViewJumpRequest = this.handlePostsViewJumpRequest.bind(this); + + const currentChannelId = ChannelStore.getCurrentId(); + const state = { + scrollType: PostsView.SCROLL_TYPE_BOTTOM, + scrollPost: null, + numPostsToDisplay: Constants.POST_CHUNK_SIZE + }; + if (currentChannelId) { + Object.assign(state, { + currentChannelIndex: 0, + channels: [currentChannelId], + postLists: [this.getChannelPosts(currentChannelId)] + }); + } else { + Object.assign(state, { + currentChannelIndex: null, + channels: [], + postLists: [] + }); + } + + this.state = state; + } + componentDidMount() { + ChannelStore.addChangeListener(this.onChannelChange); + ChannelStore.addLeaveListener(this.onChannelLeave); + PostStore.addChangeListener(this.onPostsChange); + PostStore.addPostsViewJumpListener(this.handlePostsViewJumpRequest); + } + componentWillUnmount() { + ChannelStore.removeChangeListener(this.onChannelChange); + ChannelStore.removeLeaveListener(this.onChannelLeave); + PostStore.removeChangeListener(this.onPostsChange); + PostStore.removePostsViewJumpListener(this.handlePostsViewJumpRequest); + } + handlePostsViewJumpRequest(type, post) { + switch (type) { + case Constants.PostsViewJumpTypes.BOTTOM: + this.setState({scrollType: PostsView.SCROLL_TYPE_BOTTOM}); + break; + case Constants.PostsViewJumpTypes.POST: + this.setState({ + scrollType: PostsView.SCROLL_TYPE_POST, + scrollPost: post + }); + break; + case Constants.PostsViewJumpTypes.SIDEBAR_OPEN: + this.setState({scrollType: PostsView.SIDEBAR_OPEN}); + break; + } + } + onChannelChange() { + const postLists = Object.assign({}, this.state.postLists); + const channels = this.state.channels.slice(); + const channelId = ChannelStore.getCurrentId(); + + // Has the channel really changed? + if (channelId === channels[this.state.currentChannelIndex]) { + return; + } + + PostStore.clearUnseenDeletedPosts(channelId); + + let lastViewed = Number.MAX_VALUE; + const member = ChannelStore.getMember(channelId); + if (member != null) { + lastViewed = member.last_viewed_at; + } + + let newIndex = channels.indexOf(channelId); + if (newIndex === -1) { + newIndex = channels.length; + channels.push(channelId); + postLists[newIndex] = this.getChannelPosts(channelId); + } + this.setState({ + currentChannelIndex: newIndex, + currentLastViewed: lastViewed, + scrollType: PostsView.SCROLL_TYPE_BOTTOM, + channels, + postLists}); + } + onChannelLeave(id) { + const postLists = Object.assign({}, this.state.postLists); + const channels = this.state.channels.slice(); + const index = channels.indexOf(id); + if (index !== -1) { + postLists.splice(index, 1); + channels.splice(index, 1); + } + this.setState({channels, postLists}); + } + onPostsChange() { + const channels = this.state.channels; + const postLists = Object.assign({}, this.state.postLists); + const newPostsView = this.getChannelPosts(channels[this.state.currentChannelIndex]); + + postLists[this.state.currentChannelIndex] = newPostsView; + this.setState({postLists}); + } + getChannelPosts(id) { + const postList = PostStore.getPosts(id); + + if (postList != null) { + const deletedPosts = PostStore.getUnseenDeletedPosts(id); + + if (deletedPosts && Object.keys(deletedPosts).length > 0) { + for (const pid in deletedPosts) { + if (deletedPosts.hasOwnProperty(pid)) { + postList.posts[pid] = deletedPosts[pid]; + postList.order.unshift(pid); + } + } + + postList.order.sort((a, b) => { + if (postList.posts[a].create_at > postList.posts[b].create_at) { + return -1; + } + if (postList.posts[a].create_at < postList.posts[b].create_at) { + return 1; + } + return 0; + }); + } + + const pendingPostList = PostStore.getPendingPosts(id); + + if (pendingPostList) { + postList.order = pendingPostList.order.concat(postList.order); + for (const ppid in pendingPostList.posts) { + if (pendingPostList.posts.hasOwnProperty(ppid)) { + postList.posts[ppid] = pendingPostList.posts[ppid]; + } + } + } + } + + return postList; + } + loadMorePostsTop() { + const postLists = this.state.postLists; + const channels = this.state.channels; + const currentChannelId = channels[this.state.currentChannelIndex]; + const currentPostList = postLists[this.state.currentChannelIndex]; + + this.setState({numPostsToDisplay: this.state.numPostsToDisplay + Constants.POST_CHUNK_SIZE}); + + Client.getPostsPage( + currentChannelId, + currentPostList.order.length, + Constants.POST_CHUNK_SIZE, + this.postsLoaded, + this.postsLoadedFailure + ); + } + postsLoaded(data) { + if (!data) { + return; + } + + if (data.order.length === 0) { + return; + } + + const postLists = this.state.postLists; + const currentPostList = postLists[this.state.currentChannelIndex]; + const channels = this.state.channels; + const currentChannelId = channels[this.state.currentChannelIndex]; + + var newPostList = {}; + newPostList.posts = Object.assign(currentPostList.posts, data.posts); + newPostList.order = currentPostList.order.concat(data.order); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: currentChannelId, + post_list: newPostList + }); + + Client.getProfiles(); + } + postsLoadedFailure(err) { + AsyncClient.dispatchError(err, 'getPosts'); + } + handlePostsViewScroll(atBottom) { + if (atBottom) { + this.setState({scrollType: PostsView.SCROLL_TYPE_BOTTOM}); + } else { + this.setState({scrollType: PostsView.SCROLL_TYPE_FREE}); + } + } + shouldComponentUpdate(nextProps, nextState) { + if (Utils.areStatesEqual(this.state, nextState)) { + return false; + } + + return true; + } + render() { + const postLists = this.state.postLists; + const channels = this.state.channels; + const currentChannelId = channels[this.state.currentChannelIndex]; + const channel = ChannelStore.get(currentChannelId); + + const postListCtls = []; + for (let i = 0; i < channels.length; i++) { + const isActive = (channels[i] === currentChannelId); + postListCtls.push( + <PostsView + key={'postsviewkey' + i} + isActive={isActive} + postList={postLists[i]} + scrollType={this.state.scrollType} + scrollPost={this.state.scrollPost} + postViewScrolled={this.handlePostsViewScroll} + loadMorePostsTopClicked={this.loadMorePostsTop} + numPostsToDisplay={this.state.numPostsToDisplay} + introText={channel ? createChannelIntroMessage(channel) : null} + messageSeparatorTime={this.state.currentLastViewed} + /> + ); + if ((!postLists[i] || !channel) && isActive) { + postListCtls.push( + <LoadingScreen + position='absolute' + key='loading' + /> + ); + } + } + + return ( + <div>{postListCtls}</div> + ); + } +} diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index bcdec2870..fe57bed28 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -34,12 +34,12 @@ export default class RhsThread extends React.Component { } var channelId = postList.posts[postList.order[0]].channel_id; - var pendingPostList = PostStore.getPendingPosts(channelId); + var pendingPostsList = PostStore.getPendingPosts(channelId); - if (pendingPostList) { - for (var pid in pendingPostList.posts) { - if (pendingPostList.posts.hasOwnProperty(pid)) { - postList.posts[pid] = pendingPostList.posts[pid]; + if (pendingPostsList) { + for (var pid in pendingPostsList.posts) { + if (pendingPostsList.posts.hasOwnProperty(pid)) { + postList.posts[pid] = pendingPostsList.posts[pid]; } } } diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index f7d772677..03e14ec49 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -142,7 +142,10 @@ export default class SearchAutocomplete extends React.Component { let channels = ChannelStore.getAll(); if (filter) { - channels = channels.filter((channel) => channel.name.startsWith(filter)); + channels = channels.filter((channel) => channel.name.startsWith(filter) && channel.type !== 'D'); + } else { + // don't show direct channels + channels = channels.filter((channel) => channel.type !== 'D'); } channels.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 1b81a5ee0..90865475b 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -90,14 +90,10 @@ export default class SearchBar extends React.Component { this.refs.autocomplete.handleInputChange(e.target, term); } - handleMouseInput(e) { - e.preventDefault(); - } handleUserBlur() { this.setState({focused: false}); } - handleUserFocus(e) { - e.target.select(); + handleUserFocus() { $('.search-bar__container').addClass('focused'); this.setState({focused: true}); @@ -106,14 +102,8 @@ export default class SearchBar extends React.Component { if (terms.length) { this.setState({isSearching: true}); - // append * if not present - let searchTerms = terms; - if (searchTerms.search(/\*\s*$/) === -1) { - searchTerms = searchTerms + '*'; - } - client.search( - searchTerms, + terms, (data) => { this.setState({isSearching: false}); if (utils.isMobile()) { @@ -198,7 +188,6 @@ export default class SearchBar extends React.Component { onBlur={this.handleUserBlur} onChange={this.handleUserInput} onKeyDown={this.handleKeyDown} - onMouseUp={this.handleMouseInput} /> {isSearching} <SearchAutocomplete diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 5cb6d168b..c47919885 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -1,19 +1,26 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AsyncClient = require('../utils/async_client.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -const Client = require('../utils/client.jsx'); -const Constants = require('../utils/constants.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); 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 TeamStore = require('../stores/team_store.jsx'); const UnreadChannelIndicator = require('./unread_channel_indicator.jsx'); +const TutorialTip = require('./tutorial/tutorial_tip.jsx'); + +const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); +const TeamStore = require('../stores/team_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); + +const AsyncClient = require('../utils/async_client.jsx'); +const Client = require('../utils/client.jsx'); const Utils = require('../utils/utils.jsx'); + +const Constants = require('../utils/constants.jsx'); +const Preferences = Constants.Preferences; +const TutorialSteps = Constants.TutorialSteps; + const Tooltip = ReactBootstrap.Tooltip; const OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -136,7 +143,7 @@ export default class Sidebar extends React.Component { channel.type = 'D'; } - channel.display_name = teammate.username; + channel.display_name = Utils.displayUsername(teammate.id); channel.teammate_id = teammate.id; channel.status = UserStore.getStatus(teammate.id); @@ -155,12 +162,15 @@ export default class Sidebar extends React.Component { visibleDirectChannels.sort(this.sortChannelsByDisplayName); + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + return { activeId: currentId, channels: ChannelStore.getAll(), members, visibleDirectChannels, - hiddenDirectChannelCount + hiddenDirectChannelCount, + showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.CHANNEL_POPOVER }; } @@ -178,10 +188,6 @@ export default class Sidebar extends React.Component { window.addEventListener('resize', this.handleResize); } shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areStatesEqual(nextProps, this.props)) { - return true; - } - if (!Utils.areStatesEqual(nextState, this.state)) { return true; } @@ -235,7 +241,7 @@ export default class Sidebar extends React.Component { const unread = this.getUnreadCount(); const mentionTitle = unread.mentions > 0 ? '(' + unread.mentions + ') ' : ''; const unreadTitle = unread.msgs > 0 ? '* ' : ''; - document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName; + document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + TeamStore.getCurrent().display_name + ' ' + currentSiteName; } } onScroll() { @@ -312,6 +318,51 @@ export default class Sidebar extends React.Component { this.setState({showDirectChannelsModal: false}); } + createTutorialTip() { + const screens = []; + + screens.push( + <div> + <h4>{'Channels'}</h4> + <p><strong>{'Channels'}</strong>{' organize conversations across different topics. They’re open to everyone on your team. To send private communications use '}<strong>{'Direct Messages'}</strong>{' for a single person or '}<strong>{'Private Groups'}</strong>{' for multiple people.'} + </p> + </div> + ); + + screens.push( + <div> + <h4>{'"Town Square" and "Off-Topic" channels'}</h4> + <p>{'Here are two public channels to start:'}</p> + <p> + <strong>{'Town Square'}</strong>{' is a place for team-wide communication. Everyone in your team is a member of this channel.'} + </p> + <p> + <strong>{'Off-Topic'}</strong>{' is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.'} + </p> + </div> + ); + + screens.push( + <div> + <h4>{'Creating and Joining Channels'}</h4> + <p> + {'Click '}<strong>{'"More..."'}</strong>{' to create a new channel or join an existing one.'} + </p> + <p> + {'You can also create a new channel or private group by clicking the '}<strong>{'"+" symbol'}</strong>{' next to the channel or private group header.'} + </p> + </div> + ); + + return ( + <TutorialTip + placement='right' + screens={screens} + overlayClass='tip-overlay--sidebar' + /> + ); + } + createChannelElement(channel, index, arr, handleClose) { var members = this.state.members; var activeId = this.state.activeId; @@ -448,6 +499,11 @@ export default class Sidebar extends React.Component { rowClass += ' has-close'; } + let tutorialTip = null; + if (this.state.showTutorialTip && channel.name === Constants.DEFAULT_CHANNEL) { + tutorialTip = this.createTutorialTip(); + } + return ( <li key={channel.name} @@ -464,6 +520,7 @@ export default class Sidebar extends React.Component { {badge} {closeButton} </a> + {tutorialTip} </li> ); } @@ -543,9 +600,9 @@ export default class Sidebar extends React.Component { /> <SidebarHeader - teamDisplayName={this.props.teamDisplayName} - teamName={this.props.teamName} - teamType={this.props.teamType} + teamDisplayName={TeamStore.getCurrent().display_name} + teamName={TeamStore.getCurrent().name} + teamType={TeamStore.getCurrent().type} /> <SearchBox /> @@ -631,11 +688,6 @@ export default class Sidebar extends React.Component { } Sidebar.defaultProps = { - teamType: '', - teamDisplayName: '' }; Sidebar.propTypes = { - teamType: React.PropTypes.string, - teamDisplayName: React.PropTypes.string, - teamName: React.PropTypes.string }; diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index 65e4c6d7e..3f777d93c 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -1,9 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var NavbarDropdown = require('./navbar_dropdown.jsx'); -var UserStore = require('../stores/user_store.jsx'); +const NavbarDropdown = require('./navbar_dropdown.jsx'); +const TutorialTip = require('./tutorial/tutorial_tip.jsx'); + +const UserStore = require('../stores/user_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); + const Utils = require('../utils/utils.jsx'); +const Constants = require('../utils/constants.jsx'); +const Preferences = Constants.Preferences; +const TutorialSteps = Constants.TutorialSteps; const Tooltip = ReactBootstrap.Tooltip; const OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -13,8 +20,23 @@ export default class SidebarHeader extends React.Component { super(props); this.toggleDropdown = this.toggleDropdown.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); - this.state = {}; + this.state = this.getStateFromStores(); + } + componentDidMount() { + PreferenceStore.addChangeListener(this.onPreferenceChange); + } + componentWillUnmount() { + PreferenceStore.removeChangeListener(this.onPreferenceChange); + } + getStateFromStores() { + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + + return {showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.MENU_POPOVER}; + } + onPreferenceChange() { + this.setState(this.getStateFromStores()); } toggleDropdown(e) { e.preventDefault(); @@ -24,6 +46,38 @@ export default class SidebarHeader extends React.Component { } $('.team__header').find('.dropdown-toggle').dropdown('toggle'); } + createTutorialTip() { + const screens = []; + + screens.push( + <div> + <h4>{'Main Menu'}</h4> + <p> + {'The '}<strong>{'Main Menu'}</strong>{' is where you can '} + <strong>{'Invite New Members'}</strong> + {', access your '} + <strong>{'Account Settings'}</strong> + {' and set your '}<strong>{'Theme Color'}</strong>{'.'} + </p> + <p> + {'Team administrators can also access their '}<strong>{'Team Settings'}</strong>{' from this menu.'} + </p> + </div> + ); + + return ( + <div + onClick={this.toggleDropdown} + > + <TutorialTip + ref='tip' + placement='right' + screens={screens} + overlayClass='tip-overlay--header' + /> + </div> + ); + } render() { var me = UserStore.getCurrentUser(); var profilePicture = null; @@ -41,8 +95,14 @@ export default class SidebarHeader extends React.Component { ); } + let tutorialTip = null; + if (this.state.showTutorialTip) { + tutorialTip = this.createTutorialTip(); + } + return ( <div className='team__header theme'> + {tutorialTip} <a href='#' onClick={this.toggleDropdown} diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx index 51225cbbe..e2ef60959 100644 --- a/web/react/components/sidebar_right.jsx +++ b/web/react/components/sidebar_right.jsx @@ -20,23 +20,48 @@ export default class SidebarRight extends React.Component { this.onSelectedChange = this.onSelectedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); + this.doStrangeThings = this.doStrangeThings.bind(this); + this.state = getStateFromStores(); } componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); + this.doStrangeThings(); } componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); } - componentDidUpdate() { - if (this.plScrolledToBottom) { - var postHolder = $('.post-list-holder-by-time').not('.inactive'); - postHolder.scrollTop(postHolder[0].scrollHeight); - } else { - $('.top-visible-post')[0].scrollIntoView(); + componentWillUpdate() { + PostStore.jumpPostsViewSidebarOpen(); + } + doStrangeThings() { + // We should have a better way to do this stuff + // Hence the function name. + $('.inner__wrap').removeClass('.move--right'); + $('.inner__wrap').addClass('move--left'); + $('.sidebar--left').removeClass('move--right'); + $('.sidebar--right').addClass('move--left'); + + //$('.sidebar--right').prepend('<div class="sidebar__overlay"></div>'); + + if (!(this.state.search_visible || this.state.post_right_visible)) { + $('.inner__wrap').removeClass('move--left').removeClass('move--right'); + $('.sidebar--right').removeClass('move--left'); + return ( + <div></div> + ); } + + /*setTimeout(() => { + $('.sidebar__overlay').fadeOut('200', () => { + $('.sidebar__overlay').remove(); + }); + }, 500);*/ + } + componentDidUpdate() { + this.doStrangeThings(); } onSelectedChange(fromSearch) { var newState = getStateFromStores(fromSearch); @@ -52,30 +77,6 @@ export default class SidebarRight extends React.Component { } } render() { - var postHolder = $('.post-list-holder-by-time').not('.inactive'); - const position = postHolder.scrollTop() + postHolder.height() + 14; - const bottom = postHolder[0].scrollHeight; - this.plScrolledToBottom = position >= bottom; - - if (!(this.state.search_visible || this.state.post_right_visible)) { - $('.inner__wrap').removeClass('move--left').removeClass('move--right'); - $('.sidebar--right').removeClass('move--left'); - return ( - <div></div> - ); - } - - $('.inner__wrap').removeClass('.move--right').addClass('move--left'); - $('.sidebar--left').removeClass('move--right'); - $('.sidebar--right').addClass('move--left'); - $('.sidebar--right').prepend('<div class="sidebar__overlay"></div>'); - - setTimeout(() => { - $('.sidebar__overlay').fadeOut('200', function fadeOverlay() { - $(this).remove(); - }); - }, 500); - var content = ''; if (this.state.search_visible) { diff --git a/web/react/components/time_since.jsx b/web/react/components/time_since.jsx new file mode 100644 index 000000000..c37739b9c --- /dev/null +++ b/web/react/components/time_since.jsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +var Utils = require('../utils/utils.jsx'); + +var Tooltip = ReactBootstrap.Tooltip; +var OverlayTrigger = ReactBootstrap.OverlayTrigger; + +export default class TimeSince extends React.Component { + constructor(props) { + super(props); + } + componentDidMount() { + this.intervalId = setInterval(() => { + this.forceUpdate(); + }, 30000); + } + componentWillUnmount() { + clearInterval(this.intervalId); + } + render() { + const displayDate = Utils.displayDate(this.props.eventTime); + const displayTime = Utils.displayTime(this.props.eventTime); + + const tooltip = ( + <Tooltip id={'time-since-tooltip-' + this.props.eventTime}> + {displayDate + ' at ' + displayTime} + </Tooltip> + ); + + return ( + <OverlayTrigger + delayShow={400} + placement='top' + overlay={tooltip} + > + <time className='post-profile-time'> + {Utils.displayDateTime(this.props.eventTime)} + </time> + </OverlayTrigger> + ); + } +} +TimeSince.defaultProps = { + eventTime: 0 +}; + +TimeSince.propTypes = { + eventTime: React.PropTypes.number.isRequired +}; diff --git a/web/react/components/tutorial/tutorial_intro_screens.jsx b/web/react/components/tutorial/tutorial_intro_screens.jsx new file mode 100644 index 000000000..c7abccae3 --- /dev/null +++ b/web/react/components/tutorial/tutorial_intro_screens.jsx @@ -0,0 +1,152 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const UserStore = require('../../stores/user_store.jsx'); +const ChannelStore = require('../../stores/channel_store.jsx'); +const TeamStore = require('../../stores/team_store.jsx'); +const PreferenceStore = require('../../stores/preference_store.jsx'); +const Utils = require('../../utils/utils.jsx'); +const AsyncClient = require('../../utils/async_client.jsx'); + +const Constants = require('../../utils/constants.jsx'); +const Preferences = Constants.Preferences; + +export default class TutorialIntroScreens extends React.Component { + constructor(props) { + super(props); + + this.handleNext = this.handleNext.bind(this); + this.createScreen = this.createScreen.bind(this); + + this.state = {currentScreen: 0}; + } + handleNext() { + if (this.state.currentScreen < 2) { + this.setState({currentScreen: this.state.currentScreen + 1}); + return; + } + + Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL)); + + let preference = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + + const newValue = (parseInt(preference.value, 10) + 1).toString(); + + preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), newValue); + AsyncClient.savePreferences([preference]); + } + createScreen() { + switch (this.state.currentScreen) { + case 0: + return this.createScreenOne(); + case 1: + return this.createScreenTwo(); + case 2: + return this.createScreenThree(); + } + } + createScreenOne() { + return ( + <div> + <h3>{'Welcome to:'}</h3> + <h1>{'Mattermost'}</h1> + <p>{'Your team communications all in one place, instantly searchable and available anywhere.'}</p> + <p>{'Keep your team connected to help them achieve what matters most.'}</p> + <div className='tutorial__circles'> + <div className='circle active'/> + <div className='circle'/> + <div className='circle'/> + </div> + </div> + ); + } + createScreenTwo() { + return ( + <div> + <h3>{'How Mattermost works:'}</h3> + <p>{'Communication happens in public discussion channels, private groups and direct messages.'}</p> + <p>{'Everything is archived and searchable from any web-enabled laptop, tablet or phone.'}</p> + <div className='tutorial__circles'> + <div className='circle'/> + <div className='circle active'/> + <div className='circle'/> + </div> + </div> + ); + } + createScreenThree() { + const team = TeamStore.getCurrent(); + let inviteModalLink; + if (team.type === Constants.INVITE_TEAM) { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#invite_member' + > + {'Invite teammates'} + </a> + ); + } else { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#get_link' + data-title='Team Invite' + data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} + > + {'Invite teammates'} + </a> + ); + } + + return ( + <div> + <h3>{'You’re all set'}</h3> + <p> + {inviteModalLink} + {' when you’re ready.'} + </p> + <p> + {'Need anything, just email us at '} + <a + href='mailto:feedback@mattermost.com' + target='_blank' + > + {'feedback@mattermost.com'} + </a> + {'.'} + </p> + {'Click “Next” to enter Town Square. This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} + <div className='tutorial__circles'> + <div className='circle'/> + <div className='circle'/> + <div className='circle active'/> + </div> + </div> + ); + } + render() { + const screen = this.createScreen(); + + return ( + <div className='tutorial-steps__container'> + <div className='tutorial__content'> + <div className='tutorial__steps'> + {screen} + <button + className='btn btn-primary' + tabIndex='1' + onClick={this.handleNext} + > + {'Next'} + </button> + </div> + </div> + </div> + ); + } +} diff --git a/web/react/components/tutorial/tutorial_tip.jsx b/web/react/components/tutorial/tutorial_tip.jsx new file mode 100644 index 000000000..c85acb346 --- /dev/null +++ b/web/react/components/tutorial/tutorial_tip.jsx @@ -0,0 +1,131 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const UserStore = require('../../stores/user_store.jsx'); +const PreferenceStore = require('../../stores/preference_store.jsx'); +const AsyncClient = require('../../utils/async_client.jsx'); + +const Constants = require('../../utils/constants.jsx'); +const Preferences = Constants.Preferences; + +const Overlay = ReactBootstrap.Overlay; + +export default class TutorialTip extends React.Component { + constructor(props) { + super(props); + + this.handleNext = this.handleNext.bind(this); + this.toggle = this.toggle.bind(this); + + this.state = {currentScreen: 0, show: false}; + } + toggle() { + const show = !this.state.show; + this.setState({show}); + + if (!show && this.state.currentScreen >= this.props.screens.length - 1) { + let preference = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + + const newValue = (parseInt(preference.value, 10) + 1).toString(); + + preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), newValue); + AsyncClient.savePreferences([preference]); + } + } + handleNext() { + if (this.state.currentScreen < this.props.screens.length - 1) { + this.setState({currentScreen: this.state.currentScreen + 1}); + return; + } + + this.toggle(); + } + skipTutorial(e) { + e.preventDefault(); + const preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), '999'); + AsyncClient.savePreferences([preference]); + } + render() { + const buttonText = this.state.currentScreen === this.props.screens.length - 1 ? 'Okay' : 'Next'; + + const dots = []; + if (this.props.screens.length > 1) { + for (let i = 0; i < this.props.screens.length; i++) { + if (i === this.state.currentScreen) { + dots.push( + <div + className='circle active' + key={'dotactive' + i} + /> + ); + } else { + dots.push( + <div + className='circle' + key={'dotinactive' + i} + /> + ); + } + } + } + + return ( + <div className={'tip-div ' + this.props.overlayClass}> + <img + className='tip-button' + src='/static/images/tutorialTip.gif' + width='35' + onClick={this.toggle} + ref='target' + /> + + <Overlay + show={this.state.show} + > + <div className='tip-backdrop'/> + </Overlay> + + <Overlay + placement={this.props.placement} + show={this.state.show} + rootClose={true} + onHide={this.toggle} + target={() => this.refs.target} + > + <div className={'tip-overlay ' + this.props.overlayClass}> + <div className='arrow'></div> + {this.props.screens[this.state.currentScreen]} + <div className='tutorial__circles'>{dots}</div> + <div className='text-right'> + <button + className='btn btn-default' + onClick={this.handleNext} + > + {buttonText} + </button> + <div className='tip-opt'> + {'Seen this before? '} + <a + href='#' + onClick={this.skipTutorial} + > + {'Opt out of these tips.'} + </a> + </div> + </div> + </div> + </Overlay> + </div> + ); + } +} + +TutorialTip.defaultProps = { + overlayClass: '' +}; + +TutorialTip.propTypes = { + screens: React.PropTypes.array.isRequired, + placement: React.PropTypes.string.isRequired, + overlayClass: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx index 9b0701583..93be988d1 100644 --- a/web/react/components/user_settings/manage_outgoing_hooks.jsx +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -1,10 +1,12 @@ // 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'); +const LoadingScreen = require('../loading_screen.jsx'); + +const ChannelStore = require('../../stores/channel_store.jsx'); + +const Client = require('../../utils/client.jsx'); +const Constants = require('../../utils/constants.jsx'); export default class ManageOutgoingHooks extends React.Component { constructor() { @@ -44,10 +46,10 @@ export default class ManageOutgoingHooks extends React.Component { hooks = []; } hooks.push(data); - this.setState({hooks, serverError: null, channelId: '', triggerWords: '', callbackURLs: ''}); + this.setState({hooks, addError: null, channelId: '', triggerWords: '', callbackURLs: ''}); }, (err) => { - this.setState({serverError: err}); + this.setState({addError: err.message}); } ); } @@ -74,7 +76,7 @@ export default class ManageOutgoingHooks extends React.Component { this.setState({hooks}); }, (err) => { - this.setState({serverError: err}); + this.setState({editError: err.message}); } ); } @@ -93,10 +95,10 @@ export default class ManageOutgoingHooks extends React.Component { } } - this.setState({hooks, serverError: null}); + this.setState({hooks, editError: null}); }, (err) => { - this.setState({serverError: err}); + this.setState({editError: err.message}); } ); } @@ -104,11 +106,11 @@ export default class ManageOutgoingHooks extends React.Component { Client.listOutgoingHooks( (data) => { if (data) { - this.setState({hooks: data, getHooksComplete: true, serverError: null}); + this.setState({hooks: data, getHooksComplete: true, editError: null}); } }, (err) => { - this.setState({serverError: err}); + this.setState({editError: err.message}); } ); } @@ -122,9 +124,13 @@ export default class ManageOutgoingHooks extends React.Component { this.setState({callbackURLs: e.target.value}); } render() { - let serverError; - if (this.state.serverError) { - serverError = <label className='has-error'>{this.state.serverError}</label>; + let addError; + if (this.state.addError) { + addError = <label className='has-error'>{this.state.addError}</label>; + } + let editError; + if (this.state.editError) { + addError = <label className='has-error'>{this.state.editError}</label>; } const channels = ChannelStore.getAll(); @@ -234,6 +240,7 @@ export default class ManageOutgoingHooks extends React.Component { return ( <div key='addOutgoingHook'> + {'Create webhooks to send new message events to an external integration. Please see '}<a href='http://mattermost.org/webhooks'>{'http://mattermost.org/webhooks'}</a> {' to learn more.'} <label className='control-label'>{'Add a new outgoing webhook'}</label> <div className='padding-top divider-light'></div> <div className='padding-top'> @@ -274,10 +281,11 @@ export default class ManageOutgoingHooks extends React.Component { resize={false} rows={3} onChange={this.updateCallbackURLs} + placeholder='Each URL must start with http:// or https://' /> </div> <div className='padding-top'>{'New line separated URLs that will receive the HTTP POST event'}</div> - {serverError} + {addError} </div> <div className='padding-top padding-bottom'> <a @@ -291,6 +299,7 @@ export default class ManageOutgoingHooks extends React.Component { </div> </div> {existingHooks} + {editError} </div> ); } diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx index 22a62273c..d086c78a9 100644 --- a/web/react/components/user_settings/user_settings_display.jsx +++ b/web/react/components/user_settings/user_settings_display.jsx @@ -9,8 +9,12 @@ import PreferenceStore from '../../stores/preference_store.jsx'; function getDisplayStateFromStores() { const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); + const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'username'}); - return {militaryTime: militaryTime.value}; + return { + militaryTime: militaryTime.value, + nameFormat: nameFormat.value + }; } export default class UserSettingsDisplay extends React.Component { @@ -19,15 +23,17 @@ export default class UserSettingsDisplay extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.handleClockRadio = this.handleClockRadio.bind(this); + this.handleNameRadio = this.handleNameRadio.bind(this); this.updateSection = this.updateSection.bind(this); this.handleClose = this.handleClose.bind(this); this.state = getDisplayStateFromStores(); } handleSubmit() { - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); + const timePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); + const namePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', this.state.nameFormat); - savePreferences([preference], + savePreferences([timePreference, namePreference], () => { PreferenceStore.emitChange(); this.updateSection(''); @@ -40,6 +46,9 @@ export default class UserSettingsDisplay extends React.Component { handleClockRadio(militaryTime) { this.setState({militaryTime}); } + handleNameRadio(nameFormat) { + this.setState({nameFormat}); + } updateSection(section) { this.setState(getDisplayStateFromStores()); this.props.updateSection(section); @@ -56,6 +65,7 @@ export default class UserSettingsDisplay extends React.Component { render() { const serverError = this.state.serverError || null; let clockSection; + let nameFormatSection; if (this.props.activeSection === 'clock') { const clockFormat = [false, false]; if (this.state.militaryTime === 'true') { @@ -127,6 +137,88 @@ export default class UserSettingsDisplay extends React.Component { ); } + if (this.props.activeSection === 'name_format') { + const nameFormat = [false, false, false]; + if (this.state.nameFormat === 'nickname_full_name') { + nameFormat[0] = true; + } else if (this.state.nameFormat === 'full_name') { + nameFormat[2] = true; + } else { + nameFormat[1] = true; + } + + const inputs = [ + <div key='userDisplayNameOptions'> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[0]} + onChange={this.handleNameRadio.bind(this, 'nickname_full_name')} + /> + {'Show nickname if one exists, otherwise show first and last name (team default)'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[1]} + onChange={this.handleNameRadio.bind(this, 'username')} + /> + {'Show username'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[2]} + onChange={this.handleNameRadio.bind(this, 'full_name')} + /> + {'Show first and last name'} + </label> + <br/> + </div> + <div><br/>{'How should other users be shown in Direct Messages list?'}</div> + </div> + ]; + + nameFormatSection = ( + <SettingItemMax + title='Show real names, nick names or usernames?' + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + let describe = ''; + if (this.state.nameFormat === 'username') { + describe = 'Show username'; + } else if (this.state.nameFormat === 'full_name') { + describe = 'Show first and last name'; + } else { + describe = 'Show nickname if one exists, otherwise show first and last name (team default)'; + } + + nameFormatSection = ( + <SettingItemMin + title='Show real names, nick names or usernames?' + describe={describe} + updateSection={() => { + this.props.updateSection('name_format'); + }} + /> + ); + } + return ( <div> <div className='modal-header'> @@ -151,6 +243,8 @@ export default class UserSettingsDisplay extends React.Component { <div className='divider-dark first'/> {clockSection} <div className='divider-dark'/> + {nameFormatSection} + <div className='divider-dark'/> </div> </div> ); diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 9bee74343..4a9915a1f 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -56,7 +56,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { <SettingItemMin title='Incoming Webhooks' width='medium' - describe='Manage your incoming webhooks (Developer feature)' + describe='Manage your incoming webhooks' updateSection={() => { this.updateSection('incoming-hooks'); }} |