diff options
Diffstat (limited to 'web/react')
36 files changed, 836 insertions, 275 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/admin_console/team_users.jsx b/web/react/components/admin_console/team_users.jsx index b44aba56e..7161139e6 100644 --- a/web/react/components/admin_console/team_users.jsx +++ b/web/react/components/admin_console/team_users.jsx @@ -147,9 +147,11 @@ export default class UserList extends React.Component { className='form-horizontal' role='form' > - <div className='member-list-holder'> - {memberList} - </div> + <table className='table more-table member-list-holder'> + <tbody> + {memberList} + </tbody> + </table> </form> <ResetPasswordModal user={this.state.user} diff --git a/web/react/components/center_panel.jsx b/web/react/components/center_panel.jsx index b871fe81a..242c2c637 100644 --- a/web/react/components/center_panel.jsx +++ b/web/react/components/center_panel.jsx @@ -1,17 +1,47 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var CreatePost = require('../components/create_post.jsx'); -var PostsViewContainer = require('../components/posts_view_container.jsx'); -var ChannelHeader = require('../components/channel_header.jsx'); -var Navbar = require('../components/navbar.jsx'); -var FileUploadOverlay = require('../components/file_upload_overlay.jsx'); +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'> @@ -32,7 +62,7 @@ export default class CenterPanel extends React.Component { <ChannelHeader /> </div> <div id='post-list'> - <PostsViewContainer /> + {postsContainer} </div> <div className='post-create__container' diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index 7c7770095..e90d1a666 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -139,7 +139,7 @@ export default class ChannelInviteModal extends React.Component { return ( <div - className='modal fade' + className='modal fade more-modal' id='channel_invite' tabIndex='-1' role='dialog' diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index 55b4a55c0..4fc115a92 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -30,19 +30,14 @@ export default class ChannelLoader extends React.Component { AsyncClient.getChannels(true, true); AsyncClient.getChannelExtraInfo(true); AsyncClient.findTeams(); - AsyncClient.getStatuses(); AsyncClient.getMyTeam(); + setTimeout(() => AsyncClient.getStatuses(), 3000); // temporary until statuses are reworked a bit /* Perform pending post clean-up */ PostStore.clearPendingPosts(); /* Set up interval functions */ - this.intervalId = setInterval( - function pollStatuses() { - AsyncClient.getStatuses(); - }, - 30000 - ); + this.intervalId = setInterval(() => AsyncClient.getStatuses(), 30000); /* Device tracking setup */ var iOS = (/(iPad|iPhone|iPod)/g).test(navigator.userAgent); diff --git a/web/react/components/channel_members.jsx b/web/react/components/channel_members.jsx index 86cc2464d..d0ea7278b 100644 --- a/web/react/components/channel_members.jsx +++ b/web/react/components/channel_members.jsx @@ -150,7 +150,7 @@ export default class ChannelMembers extends React.Component { return ( <div - className='modal fade' + className='modal fade more-modal' ref='modal' id='channel_members' tabIndex='-1' @@ -181,11 +181,7 @@ export default class ChannelMembers extends React.Component { ref='modalBody' className='modal-body' > - <div className='col-sm-12'> - <div className='team-member-list'> - {memberList} - </div> - </div> + {memberList} </div> <div className='modal-footer'> <button diff --git a/web/react/components/channel_view.jsx b/web/react/components/channel_view.jsx index beafa7d63..3f53a94c2 100644 --- a/web/react/components/channel_view.jsx +++ b/web/react/components/channel_view.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var CenterPanel = require('../components/center_panel.jsx'); -var Sidebar = require('../components/sidebar.jsx'); -var SidebarRight = require('../components/sidebar_right.jsx'); -var SidebarRightMenu = require('../components/sidebar_right_menu.jsx'); +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) { diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 7c601af4b..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(), @@ -318,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() { @@ -333,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; @@ -367,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) { @@ -398,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' @@ -436,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/login.jsx b/web/react/components/login.jsx index c519959af..2b9ce67ca 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -185,7 +185,7 @@ export default class Login extends React.Component { if (this.props.inviteId) { userSignUp = ( <div> - <span>{'Do not have an account? '} + <span>{`Don't have an account? `} <a href={'/signup_user_complete/?id=' + this.props.inviteId} className='signup-team-login' diff --git a/web/react/components/member_list.jsx b/web/react/components/member_list.jsx index fe744760f..70eb0a500 100644 --- a/web/react/components/member_list.jsx +++ b/web/react/components/member_list.jsx @@ -21,7 +21,8 @@ export default class MemberList extends React.Component { } return ( - <div className='member-list-holder'> + <table className='table more-table member-list-holder'> + <tbody> {members.map(function mymembers(member) { return ( <MemberListItem @@ -34,8 +35,9 @@ export default class MemberList extends React.Component { /> ); }, this)} + </tbody> {message} - </div> + </table> ); } } diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx index 8ed94680e..8251d67bc 100644 --- a/web/react/components/member_list_item.jsx +++ b/web/react/components/member_list_item.jsx @@ -37,7 +37,7 @@ export default class MemberListItem extends React.Component { invite = ( <a onClick={this.handleInvite} - className='btn btn-sm btn-primary member-invite' + className='btn btn-sm btn-primary' > <i className='glyphicon glyphicon-envelope'/> {' Add'} @@ -102,17 +102,19 @@ export default class MemberListItem extends React.Component { } return ( - <div className='row member-div'> - <img - className='post-profile-img pull-left' - src={'/api/v1/users/' + member.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} - height='36' - width='36' - /> - <span className='member-name'>{member.username}</span> - <span className='member-email'>{member.email}</span> - {invite} - </div> + <tr> + <td className='direct-channel'> + <img + className='profile-img pull-left' + src={'/api/v1/users/' + member.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} + height='36' + width='36' + /> + <div className='member-name'>{member.username}</div> + <div className='member-description'>{member.email}</div> + </td> + <td className='td--action lg'>{invite}</td> + </tr> ); } } diff --git a/web/react/components/member_list_team.jsx b/web/react/components/member_list_team.jsx index 5ca40a39d..cb2d0660b 100644 --- a/web/react/components/member_list_team.jsx +++ b/web/react/components/member_list_team.jsx @@ -15,9 +15,11 @@ export default class MemberListTeam extends React.Component { }, this); return ( - <div className='member-list-holder'> - {memberList} - </div> + <table className='table more-table member-list-holder'> + <tbody> + {memberList} + </tbody> + </table> ); } } diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index b0232fc08..40deb37f2 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -206,7 +206,7 @@ export default class MoreDirectChannels extends React.Component { return ( <Modal - className='modal-direct-channels' + dialogClassName='more-modal' show={this.props.show} onHide={this.handleHide} > diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 6c4b278a0..0b755f377 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -13,23 +13,15 @@ var UserSettingsModal = require('./user_settings/user_settings_modal.jsx'); var Constants = require('../utils/constants.jsx'); function getStateFromStores() { - let teams = []; - let teamsObject = UserStore.getTeams(); - for (let teamId in teamsObject) { + const teams = []; + const teamsObject = UserStore.getTeams(); + for (const teamId in teamsObject) { if (teamsObject.hasOwnProperty(teamId)) { teams.push(teamsObject[teamId]); } } - teams.sort(function sortByDisplayName(teamA, teamB) { - let teamADisplayName = teamA.display_name.toLowerCase(); - let teamBDisplayName = teamB.display_name.toLowerCase(); - if (teamADisplayName < teamBDisplayName) { - return -1; - } else if (teamADisplayName > teamBDisplayName) { - return 1; - } - return 0; - }); + + teams.sort(Utils.sortByDisplayName); return {teams}; } @@ -108,7 +100,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_body.jsx b/web/react/components/post_body.jsx index e1f495d54..e4094daf3 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -77,12 +77,12 @@ export default class PostBody extends React.Component { this.isGifLoading = true; const gif = new Image(); - gif.src = src; gif.onload = ( () => { this.setState({gifLoaded: true}); } ); + gif.src = src; } createGifEmbed(link) { @@ -92,7 +92,12 @@ export default class PostBody extends React.Component { if (!this.state.gifLoaded) { this.loadGif(link); - return null; + return ( + <img + className='gif-div placeholder' + height='500px' + /> + ); } return ( diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx index f5a492b85..2b81d1d79 100644 --- a/web/react/components/posts_view.jsx +++ b/web/react/components/posts_view.jsx @@ -4,6 +4,7 @@ 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) { @@ -69,6 +70,11 @@ export default class PostsView extends React.Component { 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; diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx index 9eda2a158..7671ca01d 100644 --- a/web/react/components/posts_view_container.jsx +++ b/web/react/components/posts_view_container.jsx @@ -2,15 +2,18 @@ // 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 Constants = require('../utils/constants.jsx'); -const ActionTypes = Constants.ActionTypes; + 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 LoadingScreen = require('./loading_screen.jsx'); + +const Constants = require('../utils/constants.jsx'); +const ActionTypes = Constants.ActionTypes; import {createChannelIntroMessage} from '../utils/channel_intro_mssages.jsx'; 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/search_results.jsx b/web/react/components/search_results.jsx index ce19c48f0..b56a7b006 100644 --- a/web/react/components/search_results.jsx +++ b/web/react/components/search_results.jsx @@ -83,7 +83,16 @@ export default class SearchResults extends React.Component { var ctls = null; if (noResults) { - ctls = <div className='sidebar--right__subheader'>No results</div>; + ctls = + ( + <div className='sidebar--right__subheader'> + <h4>{'NO RESULTS'}</h4> + <ul> + <li>If you're searching a partial phrase (ex. searching "rea", looking for "reach" or "reaction"), append a * to your search term</li> + <li>Due to the volume of results, two letter searches and common words like "this", "a" and "is" won't appear in search results</li> + </ul> + </div> + ); } else { ctls = results.order.map(function mymap(id) { var post = results.posts[id]; diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx index 4af46c35a..68d9cea48 100644 --- a/web/react/components/settings_sidebar.jsx +++ b/web/react/components/settings_sidebar.jsx @@ -1,10 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +var utils = require('../utils/utils.jsx'); export default class SettingsSidebar extends React.Component { componentDidUpdate() { $('.settings-modal').find('.modal-body').scrollTop(0); $('.settings-modal').find('.modal-body').perfectScrollbar('update'); + if (utils.isSafari()) { + $('.settings-modal .settings-links .nav').addClass('absolute'); + } } constructor(props) { super(props); diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 023955e97..aab9919a4 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; @@ -93,74 +100,59 @@ export default class Sidebar extends React.Component { } getStateFromStores() { const members = ChannelStore.getAllMembers(); - var teamMemberMap = UserStore.getActiveOnlyProfiles(); - var currentId = ChannelStore.getCurrentId(); - const currentUserId = UserStore.getCurrentId(); + const currentChannelId = ChannelStore.getCurrentId(); - var teammates = []; - for (var id in teamMemberMap) { - if (id === currentUserId) { - continue; - } - teammates.push(teamMemberMap[id]); - } + const channels = Object.assign([], ChannelStore.getAll()); + const publicChannels = channels.filter((channel) => channel.type === Constants.OPEN_CHANNEL); + const privateChannels = channels.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); + const directChannels = channels.filter((channel) => channel.type === Constants.DM_CHANNEL); const preferences = PreferenceStore.getPreferences(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); var visibleDirectChannels = []; - var hiddenDirectChannelCount = 0; - for (var i = 0; i < teammates.length; i++) { - const teammate = teammates[i]; - - if (teammate.id === currentUserId) { + for (var i = 0; i < directChannels.length; i++) { + const dm = directChannels[i]; + const teammate = Utils.getDirectTeammate(dm.id); + if (!teammate) { continue; } - const channelName = Utils.getDirectChannelName(currentUserId, teammate.id); - - let forceShow = false; - let channel = ChannelStore.getByName(channelName); + const member = members[dm.id]; + const msgCount = dm.total_msg_count - member.msg_count; - if (channel) { - const member = members[channel.id]; - const msgCount = channel.total_msg_count - member.msg_count; - - // always show a channel if either it is the current one or if it is unread, but it is not currently being left - forceShow = (currentId === channel.id || msgCount > 0) && !this.isLeaving.get(channel.id); - } else { - channel = {}; - channel.fake = true; - channel.name = channelName; - channel.last_post_at = 0; - channel.total_msg_count = 0; - channel.type = 'D'; - } + // always show a channel if either it is the current one or if it is unread, but it is not currently being left + const forceShow = (currentChannelId === dm.id || msgCount > 0) && !this.isLeaving.get(dm.id); + const preferenceShow = preferences.some((preference) => (preference.name === teammate.id && preference.value !== 'false')); - channel.display_name = Utils.displayUsername(teammate.id); - channel.teammate_id = teammate.id; - channel.status = UserStore.getStatus(teammate.id); + if (preferenceShow || forceShow) { + dm.display_name = Utils.displayUsername(teammate.id); + dm.teammate_id = teammate.id; + dm.status = UserStore.getStatus(teammate.id); - if (preferences.some((preference) => (preference.name === teammate.id && preference.value !== 'false'))) { - visibleDirectChannels.push(channel); - } else if (forceShow) { - // make sure that unread direct channels are visible - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); - AsyncClient.savePreferences([preference]); + visibleDirectChannels.push(dm); - visibleDirectChannels.push(channel); - } else { - hiddenDirectChannelCount += 1; + if (forceShow && !preferenceShow) { + // make sure that unread direct channels are visible + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); + AsyncClient.savePreferences([preference]); + } } } + const hiddenDirectChannelCount = UserStore.getActiveOnlyProfileList().length - visibleDirectChannels.length; + visibleDirectChannels.sort(this.sortChannelsByDisplayName); + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); + return { - activeId: currentId, - channels: ChannelStore.getAll(), + activeId: currentChannelId, members, + publicChannels, + privateChannels, visibleDirectChannels, - hiddenDirectChannelCount + hiddenDirectChannelCount, + showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.CHANNEL_POPOVER }; } @@ -308,6 +300,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; @@ -444,6 +481,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} @@ -460,6 +502,7 @@ export default class Sidebar extends React.Component { {badge} {closeButton} </a> + {tutorialTip} </li> ); } @@ -473,11 +516,9 @@ export default class Sidebar extends React.Component { this.lastUnreadChannel = null; // create elements for all 3 types of channels - const publicChannels = this.state.channels.filter((channel) => channel.type === 'O'); - const publicChannelItems = publicChannels.map(this.createChannelElement); + const publicChannelItems = this.state.publicChannels.map(this.createChannelElement); - const privateChannels = this.state.channels.filter((channel) => channel.type === 'P'); - const privateChannelItems = privateChannels.map(this.createChannelElement); + const privateChannelItems = this.state.privateChannels.map(this.createChannelElement); const directMessageItems = this.state.visibleDirectChannels.map((channel, index, arr) => { return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index 65e4c6d7e..46730e1e6 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,41 @@ 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> + <p> + {'System administrators will find a '}<strong>{'System Console'}</strong>{' option to administrate the entire system.'} + </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 +98,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/signup_team.jsx b/web/react/components/signup_team.jsx index f926f5cbb..516765a3f 100644 --- a/web/react/components/signup_team.jsx +++ b/web/react/components/signup_team.jsx @@ -12,11 +12,6 @@ export default class TeamSignUp extends React.Component { this.updatePage = this.updatePage.bind(this); - if (global.window.mm_config.EnableTeamListing === 'true') { - this.state = {page: 'team_listing'}; - return; - } - var count = 0; if (global.window.mm_config.EnableSignUpWithEmail === 'true') { @@ -41,50 +36,82 @@ export default class TeamSignUp extends React.Component { } render() { - if (this.state.page === 'team_listing') { - return ( - <div> - <h3>{'Choose a Team'}</h3> - <div className='signup-team-all'> - { - this.props.teams.map((team) => { - return ( - <div - key={'team_' + team.name} - className='signup-team-dir' - > - <a - href={'/' + team.name} + var teamListing = null; + + if (global.window.mm_config.EnableTeamListing === 'true') { + if (this.props.teams.length === 0) { + if (global.window.mm_config.EnableTeamCreation !== 'true') { + teamListing = (<div>{'There are no teams include in the Team Directory and team creation has been disabled.'}</div>); + } + } else { + teamListing = ( + <div> + <h4>{'Choose a Team'}</h4> + <div className='signup-team-all'> + { + this.props.teams.map((team) => { + return ( + <div + key={'team_' + team.name} + className='signup-team-dir' > - <div className='signup-team-dir__group'> + <a + href={'/' + team.name} + > <span className='signup-team-dir__name'>{team.display_name}</span> <span className='glyphicon glyphicon-menu-right right signup-team-dir__arrow' aria-hidden='true' /> - </div> - </a> - </div> - ); - }) - } + </a> + </div> + ); + }) + } + </div> + <h4>{'Or Create a Team'}</h4> </div> + ); + } + } + + if (global.window.mm_config.EnableTeamCreation !== 'true') { + if (teamListing == null) { + return (<div>{'Team creation has been disabled. Please contact an administrator for access.'}</div>); + } + + return ( + <div> + {teamListing} </div> ); } if (this.state.page === 'choose') { return ( - <ChoosePage - updatePage={this.updatePage} - /> + <div> + {teamListing} + <ChoosePage + updatePage={this.updatePage} + /> + </div> ); } if (this.state.page === 'email') { - return <EmailSignUpPage />; + return ( + <div> + {teamListing} + <EmailSignUpPage /> + </div> + ); } else if (this.state.page === 'gitlab') { - return <SSOSignupPage service={Constants.GITLAB_SERVICE} />; + return ( + <div> + {teamListing} + <SSOSignupPage service={Constants.GITLAB_SERVICE} /> + </div> + ); } } } diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx index 69ba44664..587ef5ec2 100644 --- a/web/react/components/team_general_tab.jsx +++ b/web/react/components/team_general_tab.jsx @@ -54,7 +54,6 @@ export default class GeneralTab extends React.Component { handleTeamListingRadio(listing) { if (global.window.mm_config.EnableTeamListing !== 'true' && listing) { - ReactDOM.findDOMNode(this.refs.teamListingRadioNo).checked = true; this.setState({clientError: 'Team directory has been disabled. Please ask a system admin to enable it.'}); } else { this.setState({allow_team_listing: listing}); @@ -278,13 +277,13 @@ export default class GeneralTab extends React.Component { </label> <br/> </div> - <div><br/>{'When allowed the team will appear on the main page as part of team directory.'}</div> + <div><br/>{'Including this team will display the team name from the Team Directory section of the Home Page, and provide a link to the sign-in page.'}</div> </div> ]; teamListingSection = ( <SettingItemMax - title='Allow in Team Directory' + title='Include this team in the Team Directory' inputs={inputs} submit={this.handleTeamListingSubmit} server_error={serverError} @@ -302,7 +301,7 @@ export default class GeneralTab extends React.Component { teamListingSection = ( <SettingItemMin - title='Allow in Team Directory' + title='Include this team in the Team Directory' describe={describe} updateSection={this.onUpdateTeamListingSection} /> @@ -337,13 +336,13 @@ export default class GeneralTab extends React.Component { </label> <br/> </div> - <div><br/>{'When allowed the team signup link will be included on the login page and anyone can signup to this team.'}</div> + <div><br/>{'When allowed, a link to account creation will be included on the sign-in page of this team and allow any visitor to sign-up.'}</div> </div> ]; openInviteSection = ( <SettingItemMax - title='Allow Open Invitations' + title='Allow anyone to sign-up from login page' inputs={inputs} submit={this.handleOpenInviteSubmit} server_error={serverError} @@ -360,7 +359,7 @@ export default class GeneralTab extends React.Component { openInviteSection = ( <SettingItemMin - title='Allow Open Invitations' + title='Allow anyone to sign-up from login page' describe={describe} updateSection={this.onUpdateOpenInviteSection} /> @@ -373,29 +372,28 @@ export default class GeneralTab extends React.Component { const inputs = []; inputs.push( - <div - key='teamInviteSetting' - className='form-group' - > - <label className='col-sm-5 control-label'>{'Invite Code'}</label> - <div className='col-sm-7'> - <input - className='form-control' - type='text' - onChange={this.updateInviteId} - value={this.state.invite_id} - maxLength='32' - /> - </div> - <div><br/>{'When allowing open invites this code is used as part of the signup process. Changing this code will invalidate the previous open signup link.'}</div> - <div className='help-text'> - <button - className='btn btn-default' - onClick={this.handleGenerateInviteId} - > - {'Re-Generate'} - </button> + <div key='teamInviteSetting'> + <div className='row'> + <label className='col-sm-5 control-label'>{'Invite Code'}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateInviteId} + value={this.state.invite_id} + maxLength='32' + /> + <div className='padding-top x2'> + <a + href='#' + onClick={this.handleGenerateInviteId} + > + {'Re-Generate'} + </a> + </div> + </div> </div> + <div className='setting-list__hint'>{'When allowing open invites this code is used as part of the signup process. Changing this code will invalidate the previous open signup link.'}</div> </div> ); @@ -413,7 +411,7 @@ export default class GeneralTab extends React.Component { inviteSection = ( <SettingItemMin title={`Invite Code`} - describe={`Click 'Edit' to re-generate invite Code.`} + describe={`Click 'Edit' to regenerate Invite Code.`} updateSection={this.onUpdateInviteIdSection} /> ); @@ -494,8 +492,11 @@ export default class GeneralTab extends React.Component { <h3 className='tab-header'>{'General Settings'}</h3> <div className='divider-dark first'/> {nameSection} + <div className='divider-light'/> {openInviteSection} + <div className='divider-light'/> {teamListingSection} + <div className='divider-light'/> {inviteSection} <div className='divider-dark'/> </div> 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..a99e9fe28 --- /dev/null +++ b/web/react/components/tutorial/tutorial_intro_screens.jsx @@ -0,0 +1,161 @@ +// 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]); + } + componentDidMount() { + $('.tutorials__scroll').perfectScrollbar(); + } + 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 communication 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 desktop, laptop 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 height = Utils.windowHeight() - 100; + const screen = this.createScreen(); + + return ( + <div + className='tutorials__scroll' + style={{height}} + > + <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> + </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/stores/channel_store.jsx b/web/react/stores/channel_store.jsx index d1f548d50..cc0d0d14b 100644 --- a/web/react/stores/channel_store.jsx +++ b/web/react/stores/channel_store.jsx @@ -4,6 +4,7 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var EventEmitter = require('events').EventEmitter; +var Utils; var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -183,16 +184,11 @@ class ChannelStoreClass extends EventEmitter { channels.push(channel); } - channels.sort(function chanSort(a, b) { - if (a.display_name.toLowerCase() < b.display_name.toLowerCase()) { - return -1; - } - if (a.display_name.toLowerCase() > b.display_name.toLowerCase()) { - return 1; - } - return 0; - }); + if (!Utils) { + Utils = require('../utils/utils.jsx'); //eslint-disable-line global-require + } + channels.sort(Utils.sortByDisplayName); this.pStoreChannels(channels); } pStoreChannels(channels) { diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index 2ad5a2ffa..4fa7224b7 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -188,12 +188,13 @@ class UserStoreClass extends EventEmitter { } getActiveOnlyProfiles() { - var active = {}; - var current = this.getProfiles(); + const active = {}; + const profiles = this.getProfiles(); + const currentId = this.getCurrentId(); - for (var key in current) { - if (current[key].delete_at === 0) { - active[key] = current[key]; + for (var key in profiles) { + if (profiles[key].delete_at === 0 && profiles[key].id !== currentId) { + active[key] = profiles[key]; } } @@ -203,9 +204,10 @@ class UserStoreClass extends EventEmitter { getActiveOnlyProfileList() { const profileMap = this.getActiveOnlyProfiles(); const profiles = []; + const currentId = this.getCurrentId(); for (const id in profileMap) { - if (profileMap.hasOwnProperty(id)) { + if (profileMap.hasOwnProperty(id) && id !== currentId) { profiles.push(profileMap[id]); } } @@ -219,6 +221,14 @@ class UserStoreClass extends EventEmitter { BrowserStore.setItem('profiles', ps); } + saveProfiles(profiles) { + const currentId = this.getCurrentId(); + if (currentId in profiles) { + delete profiles[currentId]; + } + BrowserStore.setItem('profiles', profiles); + } + setSessions(sessions) { BrowserStore.setItem('sessions', sessions); } @@ -304,15 +314,8 @@ UserStore.dispatchToken = AppDispatcher.register((payload) => { switch (action.type) { case ActionTypes.RECIEVED_PROFILES: - for (var id in action.profiles) { - // profiles can have incomplete data, so don't overwrite current user - if (id === UserStore.getCurrentId()) { - continue; - } - var profile = action.profiles[id]; - UserStore.saveProfile(profile); - UserStore.emitChange(profile.id); - } + UserStore.saveProfiles(action.profiles); + UserStore.emitChange(); break; case ActionTypes.RECIEVED_ME: UserStore.setCurrentUser(action.me); diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 75dd35e3f..205c7461c 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -588,13 +588,23 @@ export function getMe() { } export function getStatuses() { - if (isCallInProgress('getStatuses')) { + const directChannels = ChannelStore.getAll().filter((channel) => channel.type === Constants.DM_CHANNEL); + + const teammateIds = []; + for (var i = 0; i < directChannels.length; i++) { + const teammate = utils.getDirectTeammate(directChannels[i].id); + if (teammate) { + teammateIds.push(teammate.id); + } + } + + if (isCallInProgress('getStatuses') || teammateIds.length === 0) { return; } callTracker.getStatuses = utils.getTimestamp(); - client.getStatuses( - function getStatusesSuccess(data, textStatus, xhr) { + client.getStatuses(teammateIds, + (data, textStatus, xhr) => { callTracker.getStatuses = 0; if (xhr.status === 304 || !data) { @@ -606,7 +616,7 @@ export function getStatuses() { statuses: data }); }, - function getStatusesFailure(err) { + (err) => { callTracker.getStatuses = 0; dispatchError(err, 'getStatuses'); } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 7ce1346f9..003e24d33 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1069,12 +1069,13 @@ export function exportTeam(success, error) { }); } -export function getStatuses(success, error) { +export function getStatuses(ids, success, error) { $.ajax({ url: '/api/v1/users/status', dataType: 'json', contentType: 'application/json', - type: 'GET', + type: 'POST', + data: JSON.stringify(ids), success, error: function onError(xhr, status, err) { var e = handleError('getStatuses', xhr, status, err); diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 51cd41b6d..b8d346ba7 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -128,6 +128,7 @@ module.exports = { MAX_DMS: 20, DM_CHANNEL: 'D', OPEN_CHANNEL: 'O', + PRIVATE_CHANNEL: 'P', INVITE_TEAM: 'I', OPEN_TEAM: 'O', MAX_POST_LEN: 4000, @@ -315,7 +316,14 @@ module.exports = { Preferences: { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', CATEGORY_DISPLAY_SETTINGS: 'display_settings', - CATEGORY_ADVANCED_SETTINGS: 'advanced_settings' + CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', + TUTORIAL_STEP: 'tutorial_step' + }, + TutorialSteps: { + INTRO_SCREENS: 0, + POST_POPOVER: 1, + CHANNEL_POPOVER: 2, + MENU_POPOVER: 3 }, KeyCodes: { UP: 38, diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 179416ea0..3ef09211f 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -34,6 +34,11 @@ const highlightJsIni = require('highlight.js/lib/languages/ini.js'); const Constants = require('../utils/constants.jsx'); const HighlightedLanguages = Constants.HighlightedLanguages; +function markdownImageLoaded(image) { + image.style.height = 'auto'; +} +window.markdownImageLoaded = markdownImageLoaded; + class MattermostInlineLexer extends marked.InlineLexer { constructor(links, options) { super(links, options); @@ -132,6 +137,16 @@ class MattermostMarkdownRenderer extends marked.Renderer { return super.br(); } + image(href, title, text) { + let out = '<img src="' + href + '" alt="' + text + '"'; + if (title) { + out += ' title="' + title + '"'; + } + out += ' onload="window.markdownImageLoaded(this)" class="markdown-inline-img"'; + out += this.options.xhtml ? '/>' : '>'; + return out; + } + heading(text, level, raw) { const id = `${this.options.headerPrefix}${raw.toLowerCase().replace(/[^\w]+/g, '-')}`; return `<h${level} id="${id}" class="markdown__heading">${text}</h${level}>`; @@ -193,7 +208,8 @@ export function format(text, options) { const markdownOptions = { renderer: new MattermostMarkdownRenderer(null, options), sanitize: true, - gfm: true + gfm: true, + tables: true }; const tokens = marked.lexer(text, markdownOptions); diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 2de858a17..ac26107cc 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -64,22 +64,6 @@ 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 doFormatMentions(text) { - const tokens = new Map(); - let output = autolinkAtMentions(text, tokens); - output = replaceTokens(output, tokens); - return output; -} - export function sanitizeHtml(text) { let output = text; @@ -182,11 +166,15 @@ function autolinkAtMentions(text, tokens) { } let output = text; - output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); + output = output.replace(/(^|[^a-z0-9])(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); return output; } +function escapeRegex(text) { + return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + function highlightCurrentMentions(text, tokens) { let output = text; @@ -226,7 +214,7 @@ function highlightCurrentMentions(text, tokens) { } for (const mention of UserStore.getCurrentMentionKeys()) { - output = output.replace(new RegExp(`(^|\\W)(${mention})\\b`, 'gi'), replaceCurrentMentionWithToken); + output = output.replace(new RegExp(`(^|\\W)(${escapeRegex(mention)})\\b`, 'gi'), replaceCurrentMentionWithToken); } return output; @@ -306,7 +294,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { return prefix + alias; } - return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); + return output.replace(new RegExp(`()(${escapeRegex(searchTerm)})`, 'gi'), replaceSearchTermWithToken); } function replaceTokens(text, tokens) { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 296307bc6..e8d34dccd 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -59,6 +59,20 @@ export function isTestDomain() { return false; } +export function isChrome() { + if (navigator.userAgent.indexOf('Chrome') > -1) { + return true; + } + return false; +} + +export function isSafari() { + if (navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1) { + return true; + } + return false; +} + export function isInRole(roles, inRole) { var parts = roles.split(' '); for (var i = 0; i < parts.length; i++) { @@ -500,16 +514,16 @@ export function applyTheme(theme) { changeCss('#post-create', 'background:' + theme.centerChannelBg, 1); changeCss('.date-separator .separator__text, .new-separator .separator__text', 'background:' + theme.centerChannelBg, 1); changeCss('.post-image__column .post-image__details', 'background:' + theme.centerChannelBg, 1); - changeCss('.sidebar--right, .dropdown-menu, .popover', 'background:' + theme.centerChannelBg, 1); + changeCss('.sidebar--right, .dropdown-menu, .popover, .tip-overlay', 'background:' + theme.centerChannelBg, 1); changeCss('.popover.bottom>.arrow:after', 'border-bottom-color:' + theme.centerChannelBg, 1); - changeCss('.popover.right>.arrow:after', 'border-right-color:' + theme.centerChannelBg, 1); + changeCss('.popover.right>.arrow:after, .tip-overlay.tip-overlay--sidebar .arrow, .tip-overlay.tip-overlay--header .arrow', 'border-right-color:' + theme.centerChannelBg, 1); changeCss('.popover.left>.arrow:after', 'border-left-color:' + theme.centerChannelBg, 1); - changeCss('.popover.top>.arrow:after', 'border-top-color:' + theme.centerChannelBg, 1); + changeCss('.popover.top>.arrow:after, .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg, 1); changeCss('.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1); } if (theme.centerChannelColor) { - changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name', 'color:' + theme.centerChannelColor, 1); + changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name, .tip-overlay', 'color:' + theme.centerChannelColor, 1); changeCss('#post-create', 'color:' + theme.centerChannelColor, 2); changeCss('.channel-header__links a', 'fill:' + changeOpacity(theme.centerChannelColor, 0.7), 1); changeCss('.channel-header__links a:hover, .channel-header__links a:active', 'fill:' + theme.centerChannelColor, 2); @@ -519,7 +533,7 @@ export function applyTheme(theme) { changeCss('.dropdown-menu, .popover ', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 3); changeCss('.dropdown-menu, .popover ', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 2); changeCss('.dropdown-menu, .popover ', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 1); - changeCss('.post-body hr, .loading-screen .loading__content .round', 'background:' + theme.centerChannelColor, 1); + changeCss('.post-body hr, .loading-screen .loading__content .round, .tutorial__circles .circle, .tip-overlay .tutorial__circles .circle.active', 'background:' + theme.centerChannelColor, 1); changeCss('.channel-header .heading', 'color:' + theme.centerChannelColor, 1); 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); @@ -568,7 +582,7 @@ export function applyTheme(theme) { } if (theme.buttonBg) { - changeCss('.btn.btn-primary', 'background:' + theme.buttonBg, 1); + changeCss('.btn.btn-primary, .tutorial__circles .circle.active', '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); } @@ -1090,3 +1104,24 @@ export function openDirectChannelToUser(user, successCb, errorCb) { ); } } + +// Use when sorting multiple channels or teams by their `display_name` field +export function sortByDisplayName(a, b) { + let aDisplayName = ''; + let bDisplayName = ''; + + if (a && a.display_name) { + aDisplayName = a.display_name.toLowerCase(); + } + if (b && b.display_name) { + bDisplayName = b.display_name.toLowerCase(); + } + + if (aDisplayName < bDisplayName) { + return -1; + } + if (aDisplayName > bDisplayName) { + return 1; + } + return 0; +} |