diff options
Diffstat (limited to 'web/react/components')
75 files changed, 3484 insertions, 1401 deletions
diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index c8af2553d..f0a31ce90 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -90,8 +90,9 @@ export default class AccessHistoryModal extends React.Component { case '/channels/update': currentAuditDesc = 'Updated the ' + channelName + ' channel/group name'; break; - case '/channels/update_desc': - currentAuditDesc = 'Updated the ' + channelName + ' channel/group description'; + case '/channels/update_desc': // support the old path + case '/channels/update_header': + currentAuditDesc = 'Updated the ' + channelName + ' channel/group header'; break; default: let userIdField = []; diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index f770d166c..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'); @@ -18,6 +19,7 @@ var SqlSettingsTab = require('./sql_settings.jsx'); var TeamSettingsTab = require('./team_settings.jsx'); var ServiceSettingsTab = require('./service_settings.jsx'); var TeamUsersTab = require('./team_users.jsx'); +var TeamAnalyticsTab = require('./team_analytics.jsx'); export default class AdminController extends React.Component { constructor(props) { @@ -45,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}`); } } @@ -149,6 +152,10 @@ export default class AdminController extends React.Component { if (this.state.teams) { tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; } + } else if (this.state.selected === 'team_analytics') { + if (this.state.teams) { + tab = <TeamAnalyticsTab team={this.state.teams[this.state.selectedTeam]} />; + } } } diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index b0e01ff17..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: name, teamId: 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'; } @@ -121,6 +123,15 @@ export default class AdminSidebar extends React.Component { {'- Users'} </a> </li> + <li> + <a + href='#' + className={this.isSelected('team_analytics', team.id)} + onClick={this.handleClick.bind(this, 'team_analytics', team.id)} + > + {'- Statistics'} + </a> + </li> </ul> </li> </ul> diff --git a/web/react/components/admin_console/gitlab_settings.jsx b/web/react/components/admin_console/gitlab_settings.jsx index 8b0f00083..f8fb6d115 100644 --- a/web/react/components/admin_console/gitlab_settings.jsx +++ b/web/react/components/admin_console/gitlab_settings.jsx @@ -259,7 +259,6 @@ export default class GitLabSettings extends React.Component { } } - //config.GitLabSettings.Scope = ReactDOM.findDOMNode(this.refs.Scope).value.trim(); // <div className='form-group'> // <label diff --git a/web/react/components/admin_console/line_chart.jsx b/web/react/components/admin_console/line_chart.jsx new file mode 100644 index 000000000..7e2f95c84 --- /dev/null +++ b/web/react/components/admin_console/line_chart.jsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default class LineChart extends React.Component { + constructor(props) { + super(props); + + this.initChart = this.initChart.bind(this); + this.chart = null; + } + + componentDidMount() { + this.initChart(this.props); + } + + componentWillReceiveProps(nextProps) { + if (this.chart) { + this.chart.destroy(); + this.initChart(nextProps); + } + } + + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + initChart(props) { + var el = ReactDOM.findDOMNode(this); + var ctx = el.getContext('2d'); + this.chart = new Chart(ctx).Line(props.data, props.options || {}); //eslint-disable-line new-cap + } + + render() { + return ( + <canvas + width={this.props.width} + height={this.props.height} + /> + ); + } +} + +LineChart.propTypes = { + width: React.PropTypes.string, + height: React.PropTypes.string, + data: React.PropTypes.object, + options: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx new file mode 100644 index 000000000..0c9d1f61b --- /dev/null +++ b/web/react/components/admin_console/team_analytics.jsx @@ -0,0 +1,393 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Utils = require('../../utils/utils.jsx'); +var LineChart = require('./line_chart.jsx'); + +export default class TeamAnalytics extends React.Component { + constructor(props) { + super(props); + + this.getData = this.getData.bind(this); + + this.state = { + users: null, + serverError: null, + channel_open_count: null, + channel_private_count: null, + post_count: null, + post_counts_day: null, + user_counts_with_posts_day: null, + recent_active_users: null, + newly_created_users: null + }; + } + + componentDidMount() { + this.getData(this.props.team.id); + } + + getData(teamId) { + Client.getAnalytics( + teamId, + 'standard', + (data) => { + for (var index in data) { + if (data[index].name === 'channel_open_count') { + this.setState({channel_open_count: data[index].value}); + } + + if (data[index].name === 'channel_private_count') { + this.setState({channel_private_count: data[index].value}); + } + + if (data[index].name === 'post_count') { + this.setState({post_count: data[index].value}); + } + } + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + + Client.getAnalytics( + teamId, + 'post_counts_day', + (data) => { + data.reverse(); + + var chartData = { + labels: [], + datasets: [{ + label: 'Total Posts', + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + this.setState({post_counts_day: chartData}); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + + Client.getAnalytics( + teamId, + 'user_counts_with_posts_day', + (data) => { + data.reverse(); + + var chartData = { + labels: [], + datasets: [{ + label: 'Active Users With Posts', + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + this.setState({user_counts_with_posts_day: chartData}); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + + Client.getProfilesForTeam( + teamId, + (users) => { + this.setState({users}); + + var usersList = []; + for (var id in users) { + if (users.hasOwnProperty(id)) { + usersList.push(users[id]); + } + } + + usersList.sort((a, b) => { + if (a.last_activity_at < b.last_activity_at) { + return 1; + } + + if (a.last_activity_at > b.last_activity_at) { + return -1; + } + + return 0; + }); + + var recentActive = []; + for (let i = 0; i < usersList.length; i++) { + recentActive.push(usersList[i]); + if (i > 19) { + break; + } + } + + this.setState({recent_active_users: recentActive}); + + usersList.sort((a, b) => { + if (a.create_at < b.create_at) { + return 1; + } + + if (a.create_at > b.create_at) { + return -1; + } + + return 0; + }); + + var newlyCreated = []; + for (let i = 0; i < usersList.length; i++) { + newlyCreated.push(usersList[i]); + if (i > 19) { + break; + } + } + + this.setState({newly_created_users: newlyCreated}); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + componentWillReceiveProps(newProps) { + this.setState({ + users: null, + serverError: null, + channel_open_count: null, + channel_private_count: null, + post_count: null, + post_counts_day: null, + user_counts_with_posts_day: null, + recent_active_users: null, + newly_created_users: null + }); + + this.getData(newProps.team.id); + } + + componentWillUnmount() { + } + + render() { + var serverError = ''; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var totalCount = ( + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Total Users'}<i className='fa fa-users'/></div> + <div className='content'>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div> + </div> + </div> + ); + + var openChannelCount = ( + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Public Groups'}<i className='fa fa-unlock-alt'/></div> + <div className='content'>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div> + </div> + </div> + ); + + var openPrivateCount = ( + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Private Groups'}<i className='fa fa-lock'/></div> + <div className='content'>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div> + </div> + </div> + ); + + var postCount = ( + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Total Posts'}<i className='fa fa-comment'/></div> + <div className='content'>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div> + </div> + </div> + ); + + var postCountsByDay = ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div className='content'>{'Loading...'}</div> + </div> + </div> + ); + + if (this.state.post_counts_day != null) { + postCountsByDay = ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div className='content'> + <LineChart + data={this.state.post_counts_day} + width='740' + height='225' + /> + </div> + </div> + </div> + ); + } + + var usersWithPostsByDay = ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div>{'Loading...'}</div> + </div> + </div> + ); + + if (this.state.user_counts_with_posts_day != null) { + usersWithPostsByDay = ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Active Users With Posts'}</div> + <div className='content'> + <LineChart + data={this.state.user_counts_with_posts_day} + width='740' + height='225' + /> + </div> + </div> + </div> + ); + } + + var recentActiveUser = ( + <div className='recent-active-users'> + <div>{'Recent Active Users'}</div> + <div>{'Loading...'}</div> + </div> + ); + + if (this.state.recent_active_users != null) { + recentActiveUser = ( + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'>{'Recent Active Users'}</div> + <div className='content'> + <table> + <tbody> + { + this.state.recent_active_users.map((user) => { + return ( + <tr key={user.id}> + <td>{user.email}</td> + <td>{Utils.displayDateTime(user.last_activity_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + ); + } + + var newUsers = ( + <div className='recent-active-users'> + <div>{'Newly Created Users'}</div> + <div>{'Loading...'}</div> + </div> + ); + + if (this.state.newly_created_users != null) { + newUsers = ( + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'>{'Newly Created Users'}</div> + <div className='content'> + <table> + <tbody> + { + this.state.newly_created_users.map((user) => { + return ( + <tr key={user.id}> + <td>{user.email}</td> + <td>{Utils.displayDateTime(user.create_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + ); + } + + return ( + <div className='wrapper--fixed team_statistics'> + <h3>{'Statistics for ' + this.props.team.name}</h3> + {serverError} + <div className='row'> + {totalCount} + {postCount} + {openChannelCount} + {openPrivateCount} + </div> + <div className='row'> + {postCountsByDay} + </div> + <div className='row'> + {usersWithPostsByDay} + </div> + <div className='row'> + {recentActiveUser} + {newUsers} + </div> + </div> + ); + } +} + +TeamAnalytics.propTypes = { + team: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx index 9ecd14a1e..6587184ea 100644 --- a/web/react/components/admin_console/team_settings.jsx +++ b/web/react/components/admin_console/team_settings.jsx @@ -32,6 +32,7 @@ export default class TeamSettings extends React.Component { config.TeamSettings.EnableTeamCreation = ReactDOM.findDOMNode(this.refs.EnableTeamCreation).checked; config.TeamSettings.EnableUserCreation = ReactDOM.findDOMNode(this.refs.EnableUserCreation).checked; config.TeamSettings.RestrictTeamNames = ReactDOM.findDOMNode(this.refs.RestrictTeamNames).checked; + config.TeamSettings.EnableTeamListing = ReactDOM.findDOMNode(this.refs.EnableTeamListing).checked; var MaxUsersPerTeam = 50; if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10))) { @@ -243,6 +244,39 @@ export default class TeamSettings extends React.Component { </div> <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='EnableTeamListing' + > + {'Enable Team Directory: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='EnableTeamListing' + value='true' + ref='EnableTeamListing' + defaultChecked={this.props.config.TeamSettings.EnableTeamListing} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='EnableTeamListing' + value='false' + defaultChecked={!this.props.config.TeamSettings.EnableTeamListing} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'When true, teams that are configured to show in team directory will show on main page inplace of creating a new team.'}</p> + </div> + </div> + + <div className='form-group'> <div className='col-sm-12'> {serverError} <button diff --git a/web/react/components/admin_console/team_users.jsx b/web/react/components/admin_console/team_users.jsx index ffb412159..b44aba56e 100644 --- a/web/react/components/admin_console/team_users.jsx +++ b/web/react/components/admin_console/team_users.jsx @@ -33,14 +33,6 @@ export default class UserList extends React.Component { this.getTeamProfiles(this.props.team.id); } - // this.setState({ - // teamId: this.state.teamId, - // users: this.state.users, - // serverError: this.state.serverError, - // showPasswordModal: this.state.showPasswordModal, - // user: this.state.user - // }); - getTeamProfiles(teamId) { Client.getProfilesForTeam( teamId, @@ -95,8 +87,6 @@ export default class UserList extends React.Component { } doPasswordResetDismiss() { - this.state.showPasswordModal = false; - this.state.user = null; this.setState({ teamId: this.state.teamId, users: this.state.users, 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 1b709336f..20f106f30 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -3,7 +3,8 @@ const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); -const PostStore = require('../stores/post_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'); @@ -11,6 +12,7 @@ const TextFormatting = require('../utils/text_formatting.jsx'); const Utils = require('../utils/utils.jsx'); const MessageWrapper = require('./message_wrapper.jsx'); const PopoverListMembers = require('./popover_list_members.jsx'); +const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); const Constants = require('../utils/constants.jsx'); @@ -27,7 +29,9 @@ export default class ChannelHeader extends React.Component { this.handleLeave = this.handleLeave.bind(this); this.searchMentions = this.searchMentions.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.showEditChannelPurposeModal = false; + this.state = state; } getStateFromStores() { return { @@ -35,20 +39,22 @@ export default class ChannelHeader extends React.Component { memberChannel: ChannelStore.getCurrentMember(), memberTeam: UserStore.getCurrentUser(), users: ChannelStore.getCurrentExtraInfo().members, - searchVisible: PostStore.getSearchResults() !== null + searchVisible: SearchStore.getSearchResults() !== null }; } componentDidMount() { ChannelStore.addChangeListener(this.onListenerChange); ChannelStore.addExtraInfoChangeListener(this.onListenerChange); - PostStore.addSearchChangeListener(this.onListenerChange); + SearchStore.addSearchChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); + PreferenceStore.addChangeListener(this.onListenerChange); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); - PostStore.removeSearchChangeListener(this.onListenerChange); - UserStore.addChangeListener(this.onListenerChange); + SearchStore.removeSearchChangeListener(this.onListenerChange); + UserStore.removeChangeListener(this.onListenerChange); + PreferenceStore.removeChangeListener(this.onListenerChange); } onListenerChange() { const newState = this.getStateFromStores(); @@ -110,11 +116,11 @@ export default class ChannelHeader extends React.Component { bSize='large' placement='bottom' className='description' - onMouseOver={() => this.refs.descriptionOverlay.show()} - onMouseOut={() => this.refs.descriptionOverlay.hide()} + onMouseOver={() => this.refs.headerOverlay.show()} + onMouseOut={() => this.refs.headerOverlay.hide()} > <MessageWrapper - message={channel.description} + message={channel.header} /> </Popover> ); @@ -131,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); } } @@ -144,7 +150,7 @@ export default class ChannelHeader extends React.Component { if (isDirect) { dropdownContents.push( <li - key='edit_description_direct' + key='edit_header_direct' role='presentation' > <a @@ -152,11 +158,11 @@ export default class ChannelHeader extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set Channel Description... + Set Channel Header... </a> </li> ); @@ -216,7 +222,7 @@ export default class ChannelHeader extends React.Component { dropdownContents.push( <li - key='set_channel_description' + key='set_channel_header' role='presentation' > <a @@ -224,11 +230,25 @@ export default class ChannelHeader extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set {channelTerm} Description... + Set {channelTerm} Header... + </a> + </li> + ); + dropdownContents.push( + <li + key='set_channel_purpose' + role='presentation' + > + <a + role='menuitem' + href='#' + onClick={() => this.setState({showEditChannelPurposeModal: true})} + > + Set {channelTerm} Purpose... </a> </li> ); @@ -307,84 +327,91 @@ export default class ChannelHeader extends React.Component { } return ( - <table className='channel-header alt'> - <tbody> - <tr> - <th> - <div className='channel-header__info'> - <div className='dropdown'> + <div> + <table className='channel-header alt'> + <tbody> + <tr> + <th> + <div className='channel-header__info'> + <div className='dropdown'> + <a + href='#' + className='dropdown-toggle theme' + type='button' + id='channel_header_dropdown' + data-toggle='dropdown' + aria-expanded='true' + > + <strong className='heading'>{channelTitle} </strong> + <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' /> + </a> + <ul + className='dropdown-menu' + role='menu' + aria-labelledby='channel_header_dropdown' + > + {dropdownContents} + </ul> + </div> + <OverlayTrigger + trigger={['hover', 'focus']} + placement='bottom' + overlay={popoverContent} + ref='headerOverlay' + > + <div + onClick={TextFormatting.handleClick} + className='description' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: true, mentionHighlight: false})}} + /> + </OverlayTrigger> + </div> + </th> + <th> + <PopoverListMembers + members={this.state.users} + channelId={channel.id} + /> + </th> + <th className='search-bar__container'><NavbarSearchBox /></th> + <th> + <div className='dropdown channel-header__links'> <a href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' + id='channel_header_right_dropdown' data-toggle='dropdown' aria-expanded='true' > - <strong className='heading'>{channelTitle} </strong> - <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' /> + <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> </a> <ul - className='dropdown-menu' + className='dropdown-menu dropdown-menu-right' role='menu' - aria-labelledby='channel_header_dropdown' + aria-labelledby='channel_header_right_dropdown' > - {dropdownContents} + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.searchMentions} + > + Recent Mentions + </a> + </li> </ul> </div> - <OverlayTrigger - trigger={['hover', 'focus']} - placement='bottom' - overlay={popoverContent} - ref='descriptionOverlay' - > - <div - onClick={TextFormatting.handleClick} - className='description' - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.description, {singleline: true, mentionHighlight: false})}} - /> - </OverlayTrigger> - </div> - </th> - <th> - <PopoverListMembers - members={this.state.users} - channelId={channel.id} - /> - </th> - <th className='search-bar__container'><NavbarSearchBox /></th> - <th> - <div className='dropdown channel-header__links'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - id='channel_header_right_dropdown' - data-toggle='dropdown' - aria-expanded='true' - > - <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> - </a> - <ul - className='dropdown-menu dropdown-menu-right' - role='menu' - aria-labelledby='channel_header_right_dropdown' - > - <li role='presentation'> - <a - role='menuitem' - href='#' - onClick={this.searchMentions} - > - Recent Mentions - </a> - </li> - </ul> - </div> - </th> - </tr> - </tbody> - </table> + </th> + </tr> + </tbody> + </table> + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + </div> ); } } 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_comment.jsx b/web/react/components/create_comment.jsx index 435c7d542..058594165 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -8,6 +8,7 @@ const SocketStore = require('../stores/socket_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const PostStore = require('../stores/post_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); const Textbox = require('./textbox.jsx'); const MsgTyping = require('./msg_typing.jsx'); const FileUpload = require('./file_upload.jsx'); @@ -27,7 +28,7 @@ export default class CreateComment extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); - this.handleArrowUp = this.handleArrowUp.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); @@ -36,6 +37,7 @@ export default class CreateComment extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleResize = this.handleResize.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); PostStore.clearCommentDraftUploads(); @@ -45,15 +47,23 @@ export default class CreateComment extends React.Component { uploadsInProgress: draft.uploadsInProgress, previews: draft.previews, submitting: false, - windowWidth: Utils.windowWidth() + windowWidth: Utils.windowWidth(), + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value }; } componentDidMount() { + PreferenceStore.addChangeListener(this.onPreferenceChange); window.addEventListener('resize', this.handleResize); } componentWillUnmount() { + PreferenceStore.removeChangeListener(this.onPreferenceChange); window.removeEventListener('resize', this.handleResize); } + onPreferenceChange() { + this.setState({ + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); + } handleResize() { this.setState({windowWidth: Utils.windowWidth()}); } @@ -140,14 +150,16 @@ export default class CreateComment extends React.Component { this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); } commentMsgKeyPress(e) { - if (e.which === 13 && !e.shiftKey && !e.altKey) { - e.preventDefault(); - ReactDOM.findDOMNode(this.refs.textbox).blur(); - this.handleSubmit(e); + if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') { + if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { + e.preventDefault(); + ReactDOM.findDOMNode(this.refs.textbox).blur(); + this.handleSubmit(e); + } } const t = Date.now(); - if ((t - this.lastTime) > 5000) { + if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) { SocketStore.sendMessage({channel_id: this.props.channelId, action: 'typing', props: {parent_id: this.props.rootId}}); this.lastTime = t; } @@ -161,7 +173,12 @@ export default class CreateComment extends React.Component { $('.post-right__scroll').perfectScrollbar('update'); this.setState({messageText: messageText}); } - handleArrowUp(e) { + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.commentMsgKeyPress(e); + return; + } + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { e.preventDefault(); @@ -313,7 +330,7 @@ export default class CreateComment extends React.Component { <Textbox onUserInput={this.handleUserInput} onKeyPress={this.commentMsgKeyPress} - onKeyDown={this.handleArrowUp} + onKeyDown={this.handleKeyDown} messageText={this.state.messageText} createMessage='Add a comment...' initialText='' diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 8b5fc4162..1545cdfaa 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -1,20 +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 PreferenceStore = require('../stores/preference_store.jsx'); const SocketStore = require('../stores/socket_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 Constants = require('../utils/constants.jsx'); +const Preferences = Constants.Preferences; +const TutorialSteps = Constants.TutorialSteps; const ActionTypes = Constants.ActionTypes; const KeyCodes = Constants.KeyCodes; @@ -35,13 +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.handleArrowUp = this.handleArrowUp.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleResize = this.handleResize.bind(this); + this.sendMessage = this.sendMessage.bind(this); PostStore.clearDraftUploads(); const draft = this.getCurrentDraft(); + const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '0'}); this.state = { channelId: ChannelStore.getCurrentId(), @@ -51,8 +60,12 @@ export default class CreatePost extends React.Component { submitting: false, initialText: draft.messageText, windowWidth: Utils.windowWidth(), - windowHeight: Utils.windowHeight() + windowHeight: Utils.windowHeight(), + 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); } handleResize() { this.setState({ @@ -122,6 +135,11 @@ export default class CreatePost extends React.Component { post.message, false, (data) => { + if (data.response === 'not implemented') { + this.sendMessage(post); + return; + } + PostStore.storeDraft(data.channel_id, null); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); @@ -130,72 +148,82 @@ export default class CreatePost extends React.Component { } }, (err) => { - const state = {}; - state.serverError = err.message; - state.submitting = false; - this.setState(state); - } - ); - } else { - post.channel_id = this.state.channelId; - post.filenames = this.state.previews; - - const time = Utils.getTimestamp(); - const userId = UserStore.getCurrentId(); - post.pending_post_id = `${userId}:${time}`; - post.user_id = userId; - post.create_at = time; - post.root_id = this.state.rootId; - post.parent_id = this.state.parentId; - - const channel = ChannelStore.get(this.state.channelId); - - PostStore.storePendingPost(post); - PostStore.storeDraft(channel.id, null); - this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); - - Client.createPost(post, channel, - (data) => { - AsyncClient.getPosts(); - - const member = ChannelStore.getMember(channel.id); - member.msg_count = channel.total_msg_count; - member.last_viewed_at = Date.now(); - ChannelStore.setChannelMember(member); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST, - post: data - }); - }, - (err) => { - const state = {}; - - if (err.message === 'Invalid RootId parameter') { - if ($('#post_deleted').length > 0) { - $('#post_deleted').modal('show'); - } - PostStore.removePendingPost(post.pending_post_id); + if (err.sendMessage) { + this.sendMessage(post); } else { - post.state = Constants.POST_FAILED; - PostStore.updatePendingPost(post); + const state = {}; + state.serverError = err.message; + state.submitting = false; + this.setState(state); } - - state.submitting = false; - this.setState(state); } ); + } else { + this.sendMessage(post); } } + sendMessage(post) { + post.channel_id = this.state.channelId; + post.filenames = this.state.previews; + + const time = Utils.getTimestamp(); + const userId = UserStore.getCurrentId(); + post.pending_post_id = `${userId}:${time}`; + post.user_id = userId; + post.create_at = time; + post.root_id = this.state.rootId; + post.parent_id = this.state.parentId; + + const channel = ChannelStore.get(this.state.channelId); + + PostStore.storePendingPost(post); + PostStore.storeDraft(channel.id, null); + PostStore.jumpPostsViewToBottom(); + this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); + + Client.createPost(post, channel, + (data) => { + AsyncClient.getPosts(); + + const member = ChannelStore.getMember(channel.id); + member.msg_count = channel.total_msg_count; + member.last_viewed_at = Date.now(); + ChannelStore.setChannelMember(member); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST, + post: data + }); + }, + (err) => { + const state = {}; + + if (err.message === 'Invalid RootId parameter') { + if ($('#post_deleted').length > 0) { + $('#post_deleted').modal('show'); + } + PostStore.removePendingPost(post.pending_post_id); + } else { + post.state = Constants.POST_FAILED; + PostStore.updatePendingPost(post); + } + + state.submitting = false; + this.setState(state); + } + ); + } postMsgKeyPress(e) { - if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { - e.preventDefault(); - ReactDOM.findDOMNode(this.refs.textbox).blur(); - this.handleSubmit(e); + if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') { + if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { + e.preventDefault(); + ReactDOM.findDOMNode(this.refs.textbox).blur(); + this.handleSubmit(e); + } } const t = Date.now(); - if ((t - this.lastTime) > 5000) { + if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) { SocketStore.sendMessage({channel_id: this.state.channelId, action: 'typing', props: {parent_id: ''}, state: {}}); this.lastTime = t; } @@ -240,8 +268,14 @@ export default class CreatePost extends React.Component { this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews}); } handleUploadError(err, clientId) { + let message = err; + if (message && typeof message !== 'string') { + // err is an AppError from the server + message = err.message; + } + if (clientId === -1) { - this.setState({serverError: err}); + this.setState({serverError: message}); } else { const draft = PostStore.getDraft(this.state.channelId); @@ -252,7 +286,7 @@ export default class CreatePost extends React.Component { PostStore.storeDraft(this.state.channelId, draft); - this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: err}); + this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: message}); } } handleTextDrop(text) { @@ -286,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() { @@ -301,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; @@ -309,7 +352,12 @@ export default class CreatePost extends React.Component { const draft = PostStore.getDraft(channelId); return draft.previews.length + draft.uploadsInProgress.length; } - handleArrowUp(e) { + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.postMsgKeyPress(e); + return; + } + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { e.preventDefault(); @@ -330,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) { @@ -361,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' @@ -374,7 +446,7 @@ export default class CreatePost extends React.Component { <Textbox onUserInput={this.handleUserInput} onKeyPress={this.postMsgKeyPress} - onKeyDown={this.handleArrowUp} + onKeyDown={this.handleKeyDown} onHeightChange={this.resizePostHolder} messageText={this.state.messageText} createMessage='Write a message...' @@ -399,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_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx index d63a1db30..5b3c74e82 100644 --- a/web/react/components/edit_channel_modal.jsx +++ b/web/react/components/edit_channel_modal.jsx @@ -14,7 +14,7 @@ export default class EditChannelModal extends React.Component { this.onShow = this.onShow.bind(this); this.state = { - description: '', + header: '', title: '', channelId: '', serverError: '' @@ -28,32 +28,32 @@ export default class EditChannelModal extends React.Component { return; } - data.channel_description = this.state.description.trim(); + data.channel_header = this.state.header.trim(); - Client.updateChannelDesc(data, - function handleUpdateSuccess() { + Client.updateChannelHeader(data, + () => { this.setState({serverError: ''}); AsyncClient.getChannel(this.state.channelId); $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - }.bind(this), - function handleUpdateError(err) { - if (err.message === 'Invalid channel_description parameter') { - this.setState({serverError: 'This description is too long, please enter a shorter one'}); + }, + (err) => { + if (err.message === 'Invalid channel_header parameter') { + this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); } else { this.setState({serverError: err.message}); } - }.bind(this) + } ); } handleUserInput(e) { - this.setState({description: e.target.value}); + this.setState({header: e.target.value}); } handleClose() { - this.setState({description: '', serverError: ''}); + this.setState({header: '', serverError: ''}); } onShow(e) { const button = e.relatedTarget; - this.setState({description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); + this.setState({header: $(button).attr('data-header'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); } componentDidMount() { $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); @@ -73,7 +73,7 @@ export default class EditChannelModal extends React.Component { className='modal-title' ref='title' > - Edit Description + {'Edit Header'} </h4> ); if (this.state.title) { @@ -82,7 +82,7 @@ export default class EditChannelModal extends React.Component { className='modal-title' ref='title' > - Edit Description for <span className='name'>{this.state.title}</span> + {'Edit Header for '}<span className='name'>{this.state.title}</span> </h4> ); } @@ -105,17 +105,17 @@ export default class EditChannelModal extends React.Component { data-dismiss='modal' aria-label='Close' > - <span aria-hidden='true'>×</span> + <span aria-hidden='true'>{'×'}</span> </button> {editTitle} </div> <div className='modal-body'> + <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> <textarea className='form-control no-resize' rows='6' - ref='channelDesc' maxLength='1024' - value={this.state.description} + value={this.state.header} onChange={this.handleUserInput} /> {serverError} @@ -126,14 +126,14 @@ export default class EditChannelModal extends React.Component { className='btn btn-default' data-dismiss='modal' > - Cancel + {'Cancel'} </button> <button type='button' className='btn btn-primary' onClick={this.handleEdit} > - Save + {'Save'} </button> </div> </div> diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx new file mode 100644 index 000000000..4d162cfe7 --- /dev/null +++ b/web/react/components/edit_channel_purpose_modal.jsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const AsyncClient = require('../utils/async_client.jsx'); +const Client = require('../utils/client.jsx'); +const Modal = ReactBootstrap.Modal; + +export default class EditChannelPurposeModal extends React.Component { + constructor(props) { + super(props); + + this.handleHide = this.handleHide.bind(this); + this.handleSave = this.handleSave.bind(this); + + this.state = {serverError: ''}; + } + + handleHide() { + this.setState({serverError: ''}); + + if (this.props.onModalDismissed) { + this.props.onModalDismissed(); + } + } + + handleSave() { + if (!this.props.channel) { + return; + } + + const data = { + channel_id: this.props.channel.id, + channel_purpose: ReactDOM.findDOMNode(this.refs.purpose).value.trim() + }; + + Client.updateChannelPurpose(data, + () => { + AsyncClient.getChannel(this.props.channel.id); + + this.handleHide(); + }, + (err) => { + if (err.message === 'Invalid channel_purpose parameter') { + this.setState({serverError: 'This channel purpose is too long, please enter a shorter one'}); + } else { + this.setState({serverError: err.message}); + } + } + ); + } + + render() { + if (!this.props.show) { + return null; + } + + let serverError = null; + if (this.state.serverError) { + serverError = ( + <div className='form-group has-error'> + <br/> + <label className='control-label'>{this.state.serverError}</label> + </div> + ); + } + + let title = <span>{'Edit Purpose'}</span>; + if (this.props.channel.display_name) { + title = <span>{'Edit Purpose for '}<span className='name'>{this.props.channel.display_name}</span></span>; + } + + let channelTerm = 'Channel'; + if (this.props.channel.channelType === 'P') { + channelTerm = 'Group'; + } + + return ( + <Modal + className='modal-edit-channel-purpose' + show={this.props.show} + onHide={this.handleHide} + > + <Modal.Header closeButton={true}> + <Modal.Title> + {title} + </Modal.Title> + </Modal.Header> + <Modal.Body> + <p>{`Describe how this ${channelTerm} should be used.`}</p> + <textarea + ref='purpose' + className='form-control no-resize' + rows='6' + maxLength='128' + defaultValue={this.props.channel.purpose} + /> + {serverError} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.handleHide} + > + {'Cancel'} + </button> + <button + type='button' + className='btn btn-primary' + onClick={this.handleSave} + > + {'Save'} + </button> + </Modal.Footer> + </Modal> + ); + } +} + +EditChannelPurposeModal.propTypes = { + show: React.PropTypes.bool.isRequired, + channel: React.PropTypes.object, + onModalDismissed: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index b259b3c18..ef32baa7d 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -6,6 +6,10 @@ var AsyncClient = require('../utils/async_client.jsx'); var Textbox = require('./textbox.jsx'); var BrowserStore = require('../stores/browser_store.jsx'); var PostStore = require('../stores/post_store.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); + +var Constants = require('../utils/constants.jsx'); +var KeyCodes = Constants.KeyCodes; export default class EditPostModal extends React.Component { constructor() { @@ -16,6 +20,8 @@ export default class EditPostModal extends React.Component { this.handleEditKeyPress = this.handleEditKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleEditPostEvent = this.handleEditPostEvent.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); this.state = {editText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: ''}; } @@ -51,7 +57,7 @@ export default class EditPostModal extends React.Component { this.setState({editText: editMessage}); } handleEditKeyPress(e) { - if (e.which === 13 && !e.shiftKey && !e.altKey) { + if (this.state.ctrlSend === 'false' && e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { e.preventDefault(); ReactDOM.findDOMNode(this.refs.editbox).blur(); this.handleEdit(e); @@ -72,6 +78,16 @@ export default class EditPostModal extends React.Component { $(ReactDOM.findDOMNode(this.refs.modal)).modal('show'); } + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.handleEdit(e); + } + } + onPreferenceChange() { + this.setState({ + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); + } componentDidMount() { var self = this; @@ -84,7 +100,7 @@ export default class EditPostModal extends React.Component { if (!button) { return; } - self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refoucsid')}); + self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refocusid')}); }); $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', function onShown() { @@ -101,9 +117,11 @@ export default class EditPostModal extends React.Component { }); PostStore.addEditPostListener(this.handleEditPostEvent); + PreferenceStore.addChangeListener(this.onPreferenceChange); } componentWillUnmount() { - PostStore.removeEditPostListener(this.handleEditPostEvent); + PostStore.removeEditPostListner(this.handleEditPostEvent); + PreferenceStore.removeChangeListener(this.onPreferenceChange); } render() { var error = (<div className='form-group'><br /></div>); @@ -138,6 +156,7 @@ export default class EditPostModal extends React.Component { <Textbox onUserInput={this.handleEditInput} onKeyPress={this.handleEditKeyPress} + onKeyDown={this.handleKeyDown} messageText={this.state.editText} createMessage='Edit the post...' id='edit_textbox' diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx index 6311d9460..f098384aa 100644 --- a/web/react/components/error_bar.jsx +++ b/web/react/components/error_bar.jsx @@ -9,12 +9,8 @@ export default class ErrorBar extends React.Component { this.onErrorChange = this.onErrorChange.bind(this); this.handleClose = this.handleClose.bind(this); - this.prevTimer = null; this.state = ErrorStore.getLastError(); - if (this.isValidError(this.state)) { - this.prevTimer = setTimeout(this.handleClose, 10000); - } } isValidError(s) { @@ -56,16 +52,8 @@ export default class ErrorBar extends React.Component { onErrorChange() { var newState = ErrorStore.getLastError(); - if (this.prevTimer != null) { - clearInterval(this.prevTimer); - this.prevTimer = null; - } - if (newState) { this.setState(newState); - if (!this.isConnectionError(newState)) { - this.prevTimer = setTimeout(this.handleClose, 10000); - } } else { this.setState({message: null}); } diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index 4d4e8390c..e707e32f5 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -270,7 +270,7 @@ export default class FileAttachment extends React.Component { href={fileUrl} download={filenameString} data-toggle='tooltip' - title={filenameString} + title={'Download ' + filenameString} className='post-image__name' > {trimmedFilename} diff --git a/web/react/components/find_team.jsx b/web/react/components/find_team.jsx index e324f3666..bd3c11973 100644 --- a/web/react/components/find_team.jsx +++ b/web/react/components/find_team.jsx @@ -50,7 +50,7 @@ export default class FindTeam extends React.Component { if (this.state.sent) { return ( <div> - <h4>{'Find Your team'}</h4> + <h4>{'Find Your teams'}</h4> <p>{'An email was sent with links to any teams to which you are a member.'}</p> </div> ); diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx index 325e86f3d..8839bc3c7 100644 --- a/web/react/components/get_link_modal.jsx +++ b/web/react/components/get_link_modal.jsx @@ -98,7 +98,7 @@ export default class GetLinkModal extends React.Component { <br /><br /> </p> <textarea - className='form-control no-resize' + className='form-control no-resize min-height' readOnly='true' ref='textarea' value={this.state.value} diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index 86a4b04cf..bea700725 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -4,6 +4,7 @@ var utils = require('../utils/utils.jsx'); var Client = require('../utils/client.jsx'); var UserStore = require('../stores/user_store.jsx'); +var TeamStore = require('../stores/team_store.jsx'); var ConfirmModal = require('./confirm_modal.jsx'); export default class InviteMemberModal extends React.Component { @@ -292,7 +293,7 @@ export default class InviteMemberModal extends React.Component { } else { var teamInviteLink = null; if (currentUser && this.props.teamType === 'O') { - var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id; + var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id; var link = ( <a diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx index 108735caf..2b9ce67ca 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -101,7 +101,7 @@ export default class Login extends React.Component { href={'/' + teamName + '/login/gitlab'} > <span className='icon' /> - <span>with GitLab</span> + <span>{'with GitLab'}</span> </a> ); } @@ -154,7 +154,7 @@ export default class Login extends React.Component { type='submit' className='btn btn-primary' > - Sign in + {'Sign in'} </button> </div> </div> @@ -166,7 +166,7 @@ export default class Login extends React.Component { <div> {loginMessage} <div className='or__container'> - <span>or</span> + <span>{'or'}</span> </div> </div> ); @@ -176,16 +176,48 @@ export default class Login extends React.Component { if (emailSignup) { forgotPassword = ( <div className='form-group'> - <a href={'/' + teamName + '/reset_password'}>I forgot my password</a> + <a href={'/' + teamName + '/reset_password'}>{'I forgot my password'}</a> + </div> + ); + } + + let userSignUp = null; + if (this.props.inviteId) { + userSignUp = ( + <div> + <span>{`Don't have an account? `} + <a + href={'/signup_user_complete/?id=' + this.props.inviteId} + className='signup-team-login' + > + {'Create one now'} + </a> + </span> + </div> + ); + } + + let teamSignUp = null; + if (global.window.mm_config.EnableTeamCreation === 'true') { + teamSignUp = ( + <div className='margin--extra'> + <span>{'Want to create your own team? '} + <a + href='/' + className='signup-team-login' + > + {'Sign up now'} + </a> + </span> </div> ); } return ( <div className='signup-team__container'> - <h5 className='margin--less'>Sign in to:</h5> + <h5 className='margin--less'>{'Sign in to:'}</h5> <h2 className='signup-team__name'>{teamDisplayName}</h2> - <h2 className='signup-team__subdomain'>on {global.window.mm_config.SiteName}</h2> + <h2 className='signup-team__subdomain'>{'on '}{global.window.mm_config.SiteName}</h2> <form onSubmit={this.handleSubmit}> {verifiedBox} <div className={'form-group' + errorClass}> @@ -193,20 +225,12 @@ export default class Login extends React.Component { </div> {loginMessage} {emailSignup} + {userSignUp} <div className='form-group margin--extra form-group--small'> <span><a href='/find_team'>{'Find other teams'}</a></span> </div> {forgotPassword} - <div className='margin--extra'> - <span>{'Want to create your own team? '} - <a - href='/' - className='signup-team-login' - > - Sign up now - </a> - </span> - </div> + {teamSignUp} </form> </div> ); @@ -219,5 +243,6 @@ Login.defaultProps = { }; Login.propTypes = { teamName: React.PropTypes.string, - teamDisplayName: React.PropTypes.string + teamDisplayName: React.PropTypes.string, + inviteId: React.PropTypes.string }; diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx index 8c1da942d..61a24c09c 100644 --- a/web/react/components/mention_list.jsx +++ b/web/react/components/mention_list.jsx @@ -2,7 +2,7 @@ // See License.txt for license information. var UserStore = require('../stores/user_store.jsx'); -var PostStore = require('../stores/post_store.jsx'); +var SearchStore = require('../stores/search_store.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var Mention = require('./mention.jsx'); @@ -66,7 +66,7 @@ export default class MentionList extends React.Component { } } componentDidMount() { - PostStore.addMentionDataChangeListener(this.onListenerChange); + SearchStore.addMentionDataChangeListener(this.onListenerChange); $('.post-right__scroll').scroll(this.onScroll); @@ -74,7 +74,7 @@ export default class MentionList extends React.Component { $(document).click(this.onClick); } componentWillUnmount() { - PostStore.removeMentionDataChangeListener(this.onListenerChange); + SearchStore.removeMentionDataChangeListener(this.onListenerChange); $('body').off('keydown.mentionlist', '#' + this.props.id); } @@ -217,12 +217,17 @@ export default class MentionList extends React.Component { if (this.state.selectedMention === index) { isFocused = 'mentions-focus'; } + + if (!users[i].secondary_text) { + users[i].secondary_text = Utils.getFullName(users[i]); + } + mentions[index] = ( <Mention key={'mention_key_' + index} ref={'mention' + index} username={users[i].username} - secondary_text={Utils.getFullName(users[i])} + secondary_text={users[i].secondary_text} id={users[i].id} listId={index} isFocused={isFocused} diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index a0084ad30..c4f831c2e 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -109,7 +109,7 @@ export default class MoreChannels extends React.Component { <tr key={channel.id}> <td> <p className='more-name'>{channel.display_name}</p> - <p className='more-description'>{channel.description}</p> + <p className='more-purpose'>{channel.purpose}</p> </td> <td className='td--action'> {joinButton} diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 41746d1d7..b0232fc08 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -1,13 +1,7 @@ // 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 Constants = require('../utils/constants.jsx'); -const Client = require('../utils/client.jsx'); const Modal = ReactBootstrap.Modal; -const PreferenceStore = require('../stores/preference_store.jsx'); -const TeamStore = require('../stores/team_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); @@ -70,52 +64,24 @@ export default class MoreDirectChannels extends React.Component { } handleShowDirectChannel(teammate, e) { + e.preventDefault(); + if (this.state.loadingDMChannel !== -1) { return; } - e.preventDefault(); - - const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id); - let channel = ChannelStore.getByName(channelName); - - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); - AsyncClient.savePreferences([preference]); - - if (channel) { - Utils.switchChannel(channel); - - this.handleHide(); - } else { - this.setState({loadingDMChannel: teammate.id}); - - channel = { - name: channelName, - last_post_at: 0, - total_msg_count: 0, - type: 'D', - display_name: teammate.username, - teammate_id: teammate.id, - status: UserStore.getStatus(teammate.id) - }; - - Client.createDirectChannel( - channel, - teammate.id, - (data) => { - this.setState({loadingDMChannel: -1}); - - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); - - this.handleHide(); - }, - () => { - this.setState({loadingDMChannel: -1}); - window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; - } - ); - } + this.setState({loadingDMChannel: teammate.id}); + Utils.openDirectChannelToUser( + teammate, + (channel) => { + Utils.switchChannel(channel); + this.setState({loadingDMChannel: -1}); + this.handleHide(); + }, + () => { + this.setState({loadingDMChannel: -1}); + } + ); } handleUserChange() { diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx index 1bd23c55c..ccf8a2445 100644 --- a/web/react/components/msg_typing.jsx +++ b/web/react/components/msg_typing.jsx @@ -11,11 +11,11 @@ export default class MsgTyping extends React.Component { constructor(props) { super(props); - this.timer = null; - this.lastTime = 0; - this.onChange = this.onChange.bind(this); + this.updateTypingText = this.updateTypingText.bind(this); + this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this); + this.typingUsers = {}; this.state = { text: '' }; @@ -27,7 +27,7 @@ export default class MsgTyping extends React.Component { componentWillReceiveProps(newProps) { if (this.props.channelId !== newProps.channelId) { - this.setState({text: ''}); + this.updateTypingText(); } } @@ -36,28 +36,51 @@ export default class MsgTyping extends React.Component { } onChange(msg) { + let username = 'Someone'; if (msg.action === SocketEvents.TYPING && this.props.channelId === msg.channel_id && this.props.parentId === msg.props.parent_id) { - this.lastTime = new Date().getTime(); - - var username = 'Someone'; if (UserStore.hasProfile(msg.user_id)) { username = UserStore.getProfile(msg.user_id).username; } - this.setState({text: username + ' is typing...'}); - - if (!this.timer) { - this.timer = setInterval(function myTimer() { - if ((new Date().getTime() - this.lastTime) > 8000) { - this.setState({text: ''}); - } - }.bind(this), 3000); + if (this.typingUsers[username]) { + clearTimeout(this.typingUsers[username]); } + + this.typingUsers[username] = setTimeout(function myTimer(user) { + delete this.typingUsers[user]; + this.updateTypingText(); + }.bind(this, username), Constants.UPDATE_TYPING_MS); + + this.updateTypingText(); } else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) { - this.setState({text: ''}); + if (UserStore.hasProfile(msg.user_id)) { + username = UserStore.getProfile(msg.user_id).username; + } + clearTimeout(this.typingUsers[username]); + delete this.typingUsers[username]; + this.updateTypingText(); + } + } + + updateTypingText() { + const users = Object.keys(this.typingUsers); + let text = ''; + switch (users.length) { + case 0: + text = ''; + break; + case 1: + text = users[0] + ' is typing...'; + break; + default: + const last = users.pop(); + text = users.join(', ') + ' and ' + last + ' are typing...'; + break; } + + this.setState({text}); } render() { diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index f9cd525fd..f7778f25f 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -8,6 +8,7 @@ var ChannelStore = require('../stores/channel_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var MessageWrapper = require('./message_wrapper.jsx'); var NotifyCounts = require('./notify_counts.jsx'); +const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); const Utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); @@ -26,7 +27,9 @@ export default class Navbar extends React.Component { this.createCollapseButtons = this.createCollapseButtons.bind(this); this.createDropdown = this.createDropdown.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.showEditChannelPurposeModal = false; + this.state = state; } getStateFromStores() { return { @@ -106,22 +109,35 @@ export default class Navbar extends React.Component { </li> ); - var setChannelDescriptionOption = ( + var setChannelHeaderOption = ( <li role='presentation'> <a role='menuitem' href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set Channel Description... + Set Channel Header... </a> </li> ); + var setChannelPurposeOption = null; + if (!isDirect) { + setChannelPurposeOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={() => this.setState({showEditChannelPurposeModal: true})} + /> + </li> + ); + } + var addMembersOption; var leaveChannelOption; if (!isDirect && !ChannelStore.isDefault(channel)) { @@ -249,7 +265,8 @@ export default class Navbar extends React.Component { {viewInfoOption} {addMembersOption} {manageMembersOption} - {setChannelDescriptionOption} + {setChannelHeaderOption} + {setChannelPurposeOption} {notificationPreferenceOption} {renameChannelOption} {deleteChannelOption} @@ -335,10 +352,10 @@ export default class Navbar extends React.Component { <Popover bsStyle='info' placement='bottom' - id='description-popover' + id='header-popover' > <MessageWrapper - message={channel.description} + message={channel.header} options={{singleline: true, mentionHighlight: false}} /> </Popover> @@ -360,20 +377,20 @@ export default class Navbar extends React.Component { } } - if (channel.description.length === 0) { + if (channel.header.length === 0) { popoverContent = ( <Popover bsStyle='info' placement='bottom' - id='description-popover' + id='header-popover' > <div> - {'No channel description yet.'} + {'No channel header yet.'} <br/> <a href='#' data-toggle='modal' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} data-target='#edit_channel' @@ -392,17 +409,24 @@ export default class Navbar extends React.Component { var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent); return ( - <nav - className='navbar navbar-default navbar-fixed-top' - role='navigation' - > - <div className='container-fluid theme'> - <div className='navbar-header'> - {collapseButtons} - {channelMenuDropdown} + <div> + <nav + className='navbar navbar-default navbar-fixed-top' + role='navigation' + > + <div className='container-fluid theme'> + <div className='navbar-header'> + {collapseButtons} + {channelMenuDropdown} + </div> </div> - </div> - </nav> + </nav> + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + </div> ); } } diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 2b68645e5..f43bdffdf 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -58,6 +58,7 @@ export default class NavbarDropdown extends React.Component { TeamStore.addChangeListener(this.onListenerChange); $(ReactDOM.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', () => { + $('.sidebar--left .dropdown-menu').scrollTop(0); this.blockToggle = true; setTimeout(() => { this.blockToggle = false; @@ -103,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 @@ -111,7 +112,7 @@ export default class NavbarDropdown extends React.Component { data-toggle='modal' data-target='#get_link' data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id} + data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} > {'Get Team Invite Link'} </a> diff --git a/web/react/components/new_channel_flow.jsx b/web/react/components/new_channel_flow.jsx index 186cfc2b0..d6280d118 100644 --- a/web/react/components/new_channel_flow.jsx +++ b/web/react/components/new_channel_flow.jsx @@ -30,7 +30,7 @@ export default class NewChannelFlow extends React.Component { flowState: SHOW_NEW_CHANNEL, channelDisplayName: '', channelName: '', - channelDescription: '', + channelPurpose: '', nameModified: false }; } @@ -43,7 +43,7 @@ export default class NewChannelFlow extends React.Component { flowState: SHOW_NEW_CHANNEL, channelDisplayName: '', channelName: '', - channelDescription: '', + channelPurpose: '', nameModified: false }); } @@ -65,7 +65,7 @@ export default class NewChannelFlow extends React.Component { const cu = UserStore.getCurrentUser(); channel.team_id = cu.team_id; - channel.description = this.state.channelDescription; + channel.purpose = this.state.channelPurpose; channel.type = this.state.channelType; Client.createChannel(channel, @@ -109,7 +109,7 @@ export default class NewChannelFlow extends React.Component { channelDataChanged(data) { this.setState({ channelDisplayName: data.displayName, - channelDescription: data.description + channelPurpose: data.purpose }); if (!this.state.nameModified) { this.setState({channelName: Utils.cleanUpUrlable(data.displayName.trim())}); @@ -119,7 +119,7 @@ export default class NewChannelFlow extends React.Component { const channelData = { name: this.state.channelName, displayName: this.state.channelDisplayName, - description: this.state.channelDescription + purpose: this.state.channelPurpose }; let showChannelModal = false; diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx index 4e6280c99..c0cea496f 100644 --- a/web/react/components/new_channel_modal.jsx +++ b/web/react/components/new_channel_modal.jsx @@ -36,7 +36,7 @@ export default class NewChannelModal extends React.Component { handleChange() { const newData = { displayName: ReactDOM.findDOMNode(this.refs.display_name).value, - description: ReactDOM.findDOMNode(this.refs.channel_desc).value + purpose: ReactDOM.findDOMNode(this.refs.channel_purpose).value }; this.props.onDataChanged(newData); } @@ -136,22 +136,22 @@ export default class NewChannelModal extends React.Component { </div> <div className='form-group less'> <div className='col-sm-3'> - <label className='form__label control-label'>{'Description'}</label> + <label className='form__label control-label'>{'Purpose'}</label> <label className='form__label light'>{'(optional)'}</label> </div> <div className='col-sm-9'> <textarea className='form-control no-resize' - ref='channel_desc' + ref='channel_purpose' rows='4' - placeholder='Description' - maxLength='1024' - value={this.props.channelData.description} + placeholder='Purpose' + maxLength='128' + value={this.props.channelData.purpose} onChange={this.handleChange} tabIndex='2' /> <p className='input__help'> - {'Description helps others decide whether to join this channel.'} + {`Describe how this ${channelTerm} should be used.`} </p> {serverError} </div> diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 155e88600..f3c0fa0b4 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -3,9 +3,23 @@ var UserStore = require('../stores/user_store.jsx'); var Popover = ReactBootstrap.Popover; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; +var Overlay = ReactBootstrap.Overlay; +const Utils = require('../utils/utils.jsx'); + +const ChannelStore = require('../stores/channel_store.jsx'); export default class PopoverListMembers extends React.Component { + constructor(props) { + super(props); + + this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); + this.closePopover = this.closePopover.bind(this); + } + + componentWillMount() { + this.setState({showPopover: false}); + } + componentDidMount() { const originalLeave = $.fn.popover.Constructor.prototype.leave; $.fn.popover.Constructor.prototype.leave = function onLeave(obj) { @@ -27,12 +41,40 @@ export default class PopoverListMembers extends React.Component { } }; } + + componentDidUpdate() { + $(ReactDOM.findDOMNode(this.refs.memebersPopover)).find('.popover-content').perfectScrollbar(); + } + + handleShowDirectChannel(teammate, e) { + e.preventDefault(); + + Utils.openDirectChannelToUser( + teammate, + (channel, channelAlreadyExisted) => { + Utils.switchChannel(channel); + if (channelAlreadyExisted) { + this.closePopover(); + } + }, + () => { + this.closePopover(); + } + ); + } + + closePopover() { + this.setState({showPopover: false}); + } + render() { let popoverHtml = []; let count = 0; let countText = '-'; const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); + const currentUserId = UserStore.getCurrentId(); + const ch = ChannelStore.getCurrent(); if (members && teamMembers) { members.sort((a, b) => { @@ -40,13 +82,69 @@ export default class PopoverListMembers extends React.Component { }); members.forEach((m, i) => { + const details = []; + + const fullName = Utils.getFullName(m); + if (fullName) { + details.push( + <span + key={`${m.id}__full-name`} + className='full-name' + > + {fullName} + </span> + ); + } + + if (m.nickname) { + const separator = fullName ? ' - ' : ''; + details.push( + <span + key={`${m.nickname}__nickname`} + > + {separator + m.nickname} + </span> + ); + } + + let button = ''; + if (currentUserId !== m.id && ch.type !== 'D') { + button = ( + <a + href='#' + className='btn-message' + onClick={(e) => this.handleShowDirectChannel(m, e)} + > + {'Message'} + </a> + ); + } + if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { popoverHtml.push( <div - className='text--nowrap' + className='text-nowrap' key={'popover-member-' + i} > - {m.username} + + <img + className='profile-img rounded pull-left' + width='26px' + height='26px' + src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`} + /> + <div className='pull-left'> + <div + className='more-name' + > + {m.username} + </div> + </div> + <div + className='pull-right' + > + {button} + </div> </div> ); count++; @@ -61,29 +159,36 @@ export default class PopoverListMembers extends React.Component { } return ( - <OverlayTrigger - trigger='click' - placement='bottom' - rootClose={true} - overlay={ + <div> + <div + id='member_popover' + ref='member_popover_target' + onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})} + > + <div> + {countText} + <span + className='fa fa-user' + aria-hidden='true' + /> + </div> + </div> + <Overlay + rootClose={true} + onHide={this.closePopover} + show={this.state.showPopover} + target={() => this.state.popoverTarget} + placement='bottom' + > <Popover + ref='memebersPopover' title='Members' id='member-list-popover' > {popoverHtml} </Popover> - } - > - <div id='member_popover'> - <div> - {countText} - <span - className='fa fa-user' - aria-hidden='true' - /> - </div> + </Overlay> </div> - </OverlayTrigger> ); } } 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 45eae8c6a..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) { @@ -297,7 +296,7 @@ export default class PostBody extends React.Component { } let embed; - if (filenames.length === 0 && this.state.links) { + if (filenames.length === 0 && this.state.links && this.state.links.length > 0) { embed = this.createEmbed(this.state.links[0]); } @@ -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 36260d77c..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) { @@ -44,7 +43,7 @@ export default class PostInfo extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' - data-refoucsid='#post_textbox' + data-refocusid='#post_textbox' data-title={type} data-message={post.message} data-postid={post.id} @@ -126,7 +125,7 @@ export default class PostInfo extends React.Component { lastCommentClass = ' comment-icon__container__show'; } - if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING) { + if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { comments = ( <a href='#' @@ -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 3ceef478c..000000000 --- a/web/react/components/post_list.jsx +++ /dev/null @@ -1,720 +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 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-desc={channel.description} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a description'} - </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) { - return ( - <div className='channel-intro'> - <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> - <p className='channel-intro__content'> - {'Welcome to ' + channel.display_name + '!'} - <br/><br/> - {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} - <br/><br/> - {'To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”.'} - <br/> - </p> - </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-desc={channel.description} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a description'} - </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-desc={channel.description} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a description'} - </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]; - - 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/register_app_modal.jsx b/web/react/components/register_app_modal.jsx index 3d4d9bf45..c40409dcc 100644 --- a/web/react/components/register_app_modal.jsx +++ b/web/react/components/register_app_modal.jsx @@ -96,75 +96,74 @@ export default class RegisterAppModal extends React.Component { var body = ''; if (this.state.clientId === '') { body = ( - <div className='form-group user-settings'> - <h3>{'Register a New Application'}</h3> - <br/> - <label className='col-sm-4 control-label'>{'Application Name'}</label> - <div className='col-sm-7'> - <input - ref='name' - className='form-control' - type='text' - placeholder='Required' - /> - {nameError} - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Homepage URL'}</label> - <div className='col-sm-7'> - <input - ref='homepage' - className='form-control' - type='text' - placeholder='Required' - /> - {homepageError} - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Description'}</label> - <div className='col-sm-7'> - <input - ref='desc' - className='form-control' - type='text' - placeholder='Optional' - /> - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Callback URL'}</label> - <div className='col-sm-7'> - <textarea - ref='callback' - className='form-control' - type='text' - placeholder='Required' - rows='5' - /> - {callbackError} + <div className='settings-modal'> + <div className='form-horizontal user-settings'> + <h4 className='padding-bottom x3'>{'Register a New Application'}</h4> + <div className='row'> + <label className='col-sm-4 control-label'>{'Application Name'}</label> + <div className='col-sm-7'> + <input + ref='name' + className='form-control' + type='text' + placeholder='Required' + /> + {nameError} + </div> + </div> + <div className='row padding-top x2'> + <label className='col-sm-4 control-label'>{'Homepage URL'}</label> + <div className='col-sm-7'> + <input + ref='homepage' + className='form-control' + type='text' + placeholder='Required' + /> + {homepageError} + </div> + </div> + <div className='row padding-top x2'> + <label className='col-sm-4 control-label'>{'Description'}</label> + <div className='col-sm-7'> + <input + ref='desc' + className='form-control' + type='text' + placeholder='Optional' + /> + </div> + </div> + <div className='row padding-top padding-bottom x2'> + <label className='col-sm-4 control-label'>{'Callback URL'}</label> + <div className='col-sm-7'> + <textarea + ref='callback' + className='form-control' + type='text' + placeholder='Required' + rows='5' + /> + {callbackError} + </div> + </div> + {serverError} + <hr /> + <a + className='btn btn-sm theme pull-right' + href='#' + data-dismiss='modal' + aria-label='Close' + > + {'Cancel'} + </a> + <a + className='btn btn-sm btn-primary pull-right' + onClick={this.register} + > + {'Register'} + </a> </div> - <br/> - <br/> - <br/> - <br/> - <br/> - {serverError} - <a - className='btn btn-sm theme pull-right' - href='#' - data-dismiss='modal' - aria-label='Close' - > - {'Cancel'} - </a> - <a - className='btn btn-sm btn-primary pull-right' - onClick={this.register} - > - {'Register'} - </a> </div> ); } else { diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index cfff04fa2..8c6324c72 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -93,6 +93,7 @@ export default class RhsComment extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' + data-refocusid='#reply_textbox' data-title='Comment' data-message={post.message} data-postid={post.id} diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index deef389e2..21e52b438 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -79,6 +79,7 @@ export default class RhsRootPost extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' + data-refocusid='#reply_textbox' data-title={type} data-message={post.message} data-postid={post.id} 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 03c7b894c..03e14ec49 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -10,6 +10,7 @@ const patterns = new Map([ ['channels', /\b(?:in|channel):\s*(\S*)$/i], ['users', /\bfrom:\s*(\S*)$/i] ]); +const Popover = ReactBootstrap.Popover; export default class SearchAutocomplete extends React.Component { constructor(props) { @@ -36,6 +37,11 @@ export default class SearchAutocomplete extends React.Component { $(document).on('click', this.handleDocumentClick); } + componentDidUpdate() { + $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').css('max-height', $(window).height() - 200); + } + componentWillUnmount() { $(document).off('click', this.handleDocumentClick); } @@ -136,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)); @@ -193,7 +202,7 @@ export default class SearchAutocomplete extends React.Component { if (this.state.mode === 'channels') { suggestions = this.state.suggestions.map((channel, index) => { - let className = 'search-autocomplete__channel'; + let className = 'search-autocomplete__item'; if (this.state.selection === index) { className += ' selected'; } @@ -211,7 +220,7 @@ export default class SearchAutocomplete extends React.Component { }); } else if (this.state.mode === 'users') { suggestions = this.state.suggestions.map((user, index) => { - let className = 'search-autocomplete__user'; + let className = 'search-autocomplete__item'; if (this.state.selection === index) { className += ' selected'; } @@ -224,7 +233,7 @@ export default class SearchAutocomplete extends React.Component { className={className} > <img - className='profile-img' + className='profile-img rounded' src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} /> {user.username} @@ -234,12 +243,15 @@ export default class SearchAutocomplete extends React.Component { } return ( - <div - ref='container' - className='search-autocomplete' + <Popover + ref='searchPopover' + onShow={this.componentDidMount} + id='search-autocomplete__popover' + className='search-help-popover autocomplete visible' + placement='bottom' > {suggestions} - </div> + </Popover> ); } } diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index e1d36ad7d..90865475b 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -3,7 +3,7 @@ var client = require('../utils/client.jsx'); var AsyncClient = require('../utils/async_client.jsx'); -var PostStore = require('../stores/post_store.jsx'); +var SearchStore = require('../stores/search_store.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); @@ -30,17 +30,17 @@ export default class SearchBar extends React.Component { this.state = state; } getSearchTermStateFromStores() { - var term = PostStore.getSearchTerm() || ''; + var term = SearchStore.getSearchTerm() || ''; return { searchTerm: term }; } componentDidMount() { - PostStore.addSearchTermChangeListener(this.onListenerChange); + SearchStore.addSearchTermChangeListener(this.onListenerChange); this.mounted = true; } componentWillUnmount() { - PostStore.removeSearchTermChangeListener(this.onListenerChange); + SearchStore.removeSearchTermChangeListener(this.onListenerChange); this.mounted = false; } onListenerChange(doSearch, isMentionSearch) { @@ -84,20 +84,16 @@ export default class SearchBar extends React.Component { } handleUserInput(e) { var term = e.target.value; - PostStore.storeSearchTerm(term); - PostStore.emitSearchTermChange(false); + SearchStore.storeSearchTerm(term); + SearchStore.emitSearchTermChange(false); this.setState({searchTerm: term}); this.refs.autocomplete.handleInputChange(e.target, term); } - handleMouseInput(e) { - e.preventDefault(); - } handleUserBlur() { this.setState({focused: false}); } - handleUserFocus(e) { - e.target.select(); + handleUserFocus() { $('.search-bar__container').addClass('focused'); this.setState({focused: true}); @@ -105,6 +101,7 @@ export default class SearchBar extends React.Component { performSearch(terms, isMentionSearch) { if (terms.length) { this.setState({isSearching: true}); + client.search( terms, (data) => { @@ -143,8 +140,8 @@ export default class SearchBar extends React.Component { textbox.value = text; utils.setCaretPosition(textbox, preText.length + word.length); - PostStore.storeSearchTerm(text); - PostStore.emitSearchTermChange(false); + SearchStore.storeSearchTerm(text); + SearchStore.emitSearchTermChange(false); this.setState({searchTerm: text}); } @@ -178,6 +175,7 @@ export default class SearchBar extends React.Component { className='search__form relative-div' onSubmit={this.handleSubmit} style={{overflow: 'visible'}} + autoComplete='off' > <span className='glyphicon glyphicon-search sidebar__search-icon' /> <input @@ -190,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 30e15d0ad..b56a7b006 100644 --- a/web/react/components/search_results.jsx +++ b/web/react/components/search_results.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostStore = require('../stores/post_store.jsx'); +var SearchStore = require('../stores/search_store.jsx'); var UserStore = require('../stores/user_store.jsx'); var SearchBox = require('./search_bar.jsx'); var Utils = require('../utils/utils.jsx'); @@ -9,7 +9,7 @@ var SearchResultsHeader = require('./search_results_header.jsx'); var SearchResultsItem = require('./search_results_item.jsx'); function getStateFromStores() { - return {results: PostStore.getSearchResults()}; + return {results: SearchStore.getSearchResults()}; } export default class SearchResults extends React.Component { @@ -30,7 +30,7 @@ export default class SearchResults extends React.Component { componentDidMount() { this.mounted = true; - PostStore.addSearchChangeListener(this.onChange); + SearchStore.addSearchChangeListener(this.onChange); this.resize(); window.addEventListener('resize', this.handleResize); } @@ -40,7 +40,7 @@ export default class SearchResults extends React.Component { } componentWillUnmount() { - PostStore.removeSearchChangeListener(this.onChange); + SearchStore.removeSearchChangeListener(this.onChange); this.mounted = false; window.removeEventListener('resize', this.handleResize); } @@ -78,12 +78,21 @@ export default class SearchResults extends React.Component { searchForm = <SearchBox />; } var noResults = (!results || !results.order || !results.order.length); - var searchTerm = PostStore.getSearchTerm(); + var searchTerm = SearchStore.getSearchTerm(); 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/search_results_item.jsx b/web/react/components/search_results_item.jsx index d212e47a3..a8bd4db2c 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostStore = require('../stores/post_store.jsx'); +var SearchStore = require('../stores/search_store.jsx'); var ChannelStore = require('../stores/channel_store.jsx'); var UserStore = require('../stores/user_store.jsx'); var UserProfile = require('./user_profile.jsx'); @@ -32,7 +32,7 @@ export default class SearchResultsItem extends React.Component { AppDispatcher.handleServerAction({ type: ActionTypes.RECIEVED_POST_SELECTED, post_list: data, - from_search: PostStore.getSearchTerm() + from_search: SearchStore.getSearchTerm() }); AppDispatcher.handleServerAction({ diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx index 774f98a43..d6c4b0d4b 100644 --- a/web/react/components/setting_item_max.jsx +++ b/web/react/components/setting_item_max.jsx @@ -35,8 +35,10 @@ export default class SettingItemMax extends React.Component { var widthClass; if (this.props.width === 'full') { widthClass = 'col-sm-12'; - } else { + } else if (this.props.width === 'medium') { widthClass = 'col-sm-10 col-sm-offset-2'; + } else { + widthClass = 'col-sm-9 col-sm-offset-3'; } return ( diff --git a/web/react/components/setting_picture.jsx b/web/react/components/setting_picture.jsx index b6bcb13a6..e69412cca 100644 --- a/web/react/components/setting_picture.jsx +++ b/web/react/components/setting_picture.jsx @@ -42,7 +42,7 @@ export default class SettingPicture extends React.Component { img = ( <img ref='image' - className='profile-img' + className='profile-img rounded' src='' /> ); @@ -50,7 +50,7 @@ export default class SettingPicture extends React.Component { img = ( <img ref='image' - className='profile-img' + className='profile-img rounded' src={this.props.src} /> ); 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 ed2c84057..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; @@ -40,6 +47,9 @@ export default class Sidebar extends React.Component { this.hideMoreDirectChannelsModal = this.hideMoreDirectChannelsModal.bind(this); this.createChannelElement = this.createChannelElement.bind(this); + this.updateTitle = this.updateTitle.bind(this); + this.setUnreadCountPerChannel = this.setUnreadCountPerChannel.bind(this); + this.getUnreadCount = this.getUnreadCount.bind(this); this.isLeaving = new Map(); @@ -48,8 +58,45 @@ export default class Sidebar extends React.Component { state.showDirectChannelsModal = false; state.loadingDMChannel = -1; state.windowWidth = Utils.windowWidth(); - this.state = state; + + this.unreadCountPerChannel = {}; + this.setUnreadCountPerChannel(); + } + setUnreadCountPerChannel() { + const channels = ChannelStore.getAll(); + const members = ChannelStore.getAllMembers(); + const channelUnreadCounts = {}; + + channels.forEach((ch) => { + const chMember = members[ch.id]; + let chMentionCount = chMember.mention_count; + let chUnreadCount = ch.total_msg_count - chMember.msg_count - chMentionCount; + + if (ch.type === 'D') { + chMentionCount = chUnreadCount; + chUnreadCount = 0; + } + + channelUnreadCounts[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount}; + }); + + this.unreadCountPerChannel = channelUnreadCounts; + } + getUnreadCount(channelId) { + let mentions = 0; + let msgs = 0; + + if (channelId) { + return this.unreadCountPerChannel[channelId] ? this.unreadCountPerChannel[channelId] : {msgs, mentions}; + } + + Object.keys(this.unreadCountPerChannel).forEach((chId) => { + msgs += this.unreadCountPerChannel[chId].msgs; + mentions += this.unreadCountPerChannel[chId].mentions; + }); + + return {msgs, mentions}; } getStateFromStores() { const members = ChannelStore.getAllMembers(); @@ -96,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); @@ -115,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 }; } @@ -138,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; } @@ -192,7 +238,10 @@ export default class Sidebar extends React.Component { currentChannelName = Utils.getDirectTeammate(channel.id).username; } - document.title = currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName; + const unread = this.getUnreadCount(); + const mentionTitle = unread.mentions > 0 ? '(' + unread.mentions + ') ' : ''; + const unreadTitle = unread.msgs > 0 ? '* ' : ''; + document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + TeamStore.getCurrent().display_name + ' ' + currentSiteName; } } onScroll() { @@ -269,10 +318,56 @@ 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; var channelMember = members[channel.id]; + var unreadCount = this.getUnreadCount(channel.id); var msgCount; var linkClass = ''; @@ -284,7 +379,7 @@ export default class Sidebar extends React.Component { var unread = false; if (channelMember) { - msgCount = channel.total_msg_count - channelMember.msg_count; + msgCount = unreadCount.msgs + unreadCount.mentions; unread = (msgCount > 0 && channelMember.notify_props.mark_unread !== 'mention') || channelMember.mention_count > 0; } @@ -301,16 +396,8 @@ export default class Sidebar extends React.Component { var badge = null; if (channelMember) { - if (channel.type === 'D') { - // direct message channels show badges for any number of unread posts - msgCount = channel.total_msg_count - channelMember.msg_count; - if (msgCount > 0) { - badge = <span className='badge pull-right small'>{msgCount}</span>; - this.badgesActive = true; - } - } else if (channelMember.mention_count > 0) { - // public and private channels only show badges for mentions - badge = <span className='badge pull-right small'>{channelMember.mention_count}</span>; + if (unreadCount.mentions) { + badge = <span className='badge pull-right small'>{unreadCount.mentions}</span>; this.badgesActive = true; } } else if (this.state.loadingDMChannel === index && channel.type === 'D') { @@ -412,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} @@ -428,12 +520,15 @@ export default class Sidebar extends React.Component { {badge} {closeButton} </a> + {tutorialTip} </li> ); } render() { this.badgesActive = false; + this.setUnreadCountPerChannel(); + // keep track of the first and last unread channels so we can use them to set the unread indicators this.firstUnreadChannel = null; this.lastUnreadChannel = null; @@ -505,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 /> @@ -593,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 de28a8374..3f777d93c 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -1,17 +1,42 @@ // 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; export default class SidebarHeader extends React.Component { constructor(props) { super(props); this.toggleDropdown = this.toggleDropdown.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); + + 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'}); - this.state = {}; + return {showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.MENU_POPOVER}; + } + onPreferenceChange() { + this.setState(this.getStateFromStores()); } toggleDropdown(e) { e.preventDefault(); @@ -21,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; @@ -38,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} @@ -47,7 +110,15 @@ export default class SidebarHeader extends React.Component { {profilePicture} <div className='header__info'> <div className='user__name'>{'@' + me.username}</div> + <OverlayTrigger + trigger={['hover', 'focus']} + delayShow={1000} + placement='bottom' + overlay={<Tooltip id='team-name__tooltip'>{this.props.teamDisplayName}</Tooltip>} + ref='descriptionOverlay' + > <div className='team__name'>{this.props.teamDisplayName}</div> + </OverlayTrigger> </div> </a> <NavbarDropdown diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx index 4e6985a86..e2ef60959 100644 --- a/web/react/components/sidebar_right.jsx +++ b/web/react/components/sidebar_right.jsx @@ -3,11 +3,12 @@ var SearchResults = require('./search_results.jsx'); var RhsThread = require('./rhs_thread.jsx'); +var SearchStore = require('../stores/search_store.jsx'); var PostStore = require('../stores/post_store.jsx'); var Utils = require('../utils/utils.jsx'); function getStateFromStores() { - return {search_visible: PostStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: PostStore.getIsMentionSearch()}; + return {search_visible: SearchStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: SearchStore.getIsMentionSearch()}; } export default class SidebarRight extends React.Component { @@ -19,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() { - PostStore.addSearchChangeListener(this.onSearchChange); + SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); + this.doStrangeThings(); } componentWillUnmount() { - PostStore.removeSearchChangeListener(this.onSearchChange); + 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); @@ -51,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/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index fddc98c9d..9350bbd42 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. var UserStore = require('../stores/user_store.jsx'); +var TeamStore = require('../stores/team_store.jsx'); var client = require('../utils/client.jsx'); var utils = require('../utils/utils.jsx'); @@ -51,7 +52,7 @@ export default class SidebarRightMenu extends React.Component { data-toggle='modal' data-target='#get_link' data-title='Team Invite' - data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id} + data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} ><i className='glyphicon glyphicon-link'></i>Get Team Invite Link</a> </li> ); diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx index 1858703ef..37760a2a2 100644 --- a/web/react/components/signup_team.jsx +++ b/web/react/components/signup_team.jsx @@ -36,18 +36,88 @@ export default class TeamSignUp extends React.Component { } render() { + 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> + <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} + > + <div className='signup-team-dir__group'> + <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> + ); + }) + } + </div> + </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> + ); } } } + +TeamSignUp.propTypes = { + teams: React.PropTypes.array +}; + diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx index 923180e27..587ef5ec2 100644 --- a/web/react/components/team_general_tab.jsx +++ b/web/react/components/team_general_tab.jsx @@ -6,29 +6,111 @@ const SettingItemMax = require('./setting_item_max.jsx'); const Client = require('../utils/client.jsx'); const Utils = require('../utils/utils.jsx'); +const TeamStore = require('../stores/team_store.jsx'); export default class GeneralTab extends React.Component { constructor(props) { super(props); this.handleNameSubmit = this.handleNameSubmit.bind(this); + this.handleInviteIdSubmit = this.handleInviteIdSubmit.bind(this); + this.handleOpenInviteSubmit = this.handleOpenInviteSubmit.bind(this); + this.handleTeamListingSubmit = this.handleTeamListingSubmit.bind(this); this.handleClose = this.handleClose.bind(this); - this.onUpdateSection = this.onUpdateSection.bind(this); + this.onUpdateNameSection = this.onUpdateNameSection.bind(this); this.updateName = this.updateName.bind(this); + this.onUpdateInviteIdSection = this.onUpdateInviteIdSection.bind(this); + this.updateInviteId = this.updateInviteId.bind(this); + this.onUpdateOpenInviteSection = this.onUpdateOpenInviteSection.bind(this); + this.handleOpenInviteRadio = this.handleOpenInviteRadio.bind(this); + this.onUpdateTeamListingSection = this.onUpdateTeamListingSection.bind(this); + this.handleTeamListingRadio = this.handleTeamListingRadio.bind(this); + this.handleGenerateInviteId = this.handleGenerateInviteId.bind(this); - this.state = {name: this.props.teamDisplayName, serverError: '', clientError: ''}; + this.state = { + name: props.team.display_name, + invite_id: props.team.invite_id, + allow_open_invite: props.team.allow_open_invite, + allow_team_listing: props.team.allow_team_listing, + serverError: '', + clientError: '' + }; } + + handleGenerateInviteId(e) { + e.preventDefault(); + + var newId = ''; + for (var i = 0; i < 32; i++) { + newId += Math.floor(Math.random() * 16).toString(16); + } + + this.setState({invite_id: newId}); + } + + handleOpenInviteRadio(openInvite) { + this.setState({allow_open_invite: openInvite}); + } + + handleTeamListingRadio(listing) { + if (global.window.mm_config.EnableTeamListing !== 'true' && listing) { + this.setState({clientError: 'Team directory has been disabled. Please ask a system admin to enable it.'}); + } else { + this.setState({allow_team_listing: listing}); + } + } + + handleOpenInviteSubmit(e) { + e.preventDefault(); + + var state = {serverError: '', clientError: ''}; + + var data = this.props.team; + data.allow_open_invite = this.state.allow_open_invite; + Client.updateTeam(data, + (team) => { + TeamStore.saveTeam(team); + TeamStore.emitChange(); + this.props.updateSection(''); + }, + (err) => { + state.serverError = err.message; + this.setState(state); + } + ); + } + + handleTeamListingSubmit(e) { + e.preventDefault(); + + var state = {serverError: '', clientError: ''}; + + var data = this.props.team; + data.allow_team_listing = this.state.allow_team_listing; + Client.updateTeam(data, + (team) => { + TeamStore.saveTeam(team); + TeamStore.emitChange(); + this.props.updateSection(''); + }, + (err) => { + state.serverError = err.message; + this.setState(state); + } + ); + } + handleNameSubmit(e) { e.preventDefault(); - let state = {serverError: '', clientError: ''}; + var state = {serverError: '', clientError: ''}; let valid = true; const name = this.state.name.trim(); if (!name) { state.clientError = 'This field is required'; valid = false; - } else if (name === this.props.teamDisplayName) { + } else if (name === this.props.team.display_name) { state.clientError = 'Please choose a new name for your team'; valid = false; } else { @@ -41,37 +123,76 @@ export default class GeneralTab extends React.Component { return; } - let data = {}; - data.new_name = name; + var data = this.props.team; + data.display_name = this.state.name; + Client.updateTeam(data, + (team) => { + TeamStore.saveTeam(team); + TeamStore.emitChange(); + this.props.updateSection(''); + }, + (err) => { + state.serverError = err.message; + this.setState(state); + } + ); + } + + handleInviteIdSubmit(e) { + e.preventDefault(); + + var state = {serverError: '', clientError: ''}; + let valid = true; + + const inviteId = this.state.invite_id.trim(); + if (inviteId) { + state.clientError = ''; + } else { + state.clientError = 'This field is required'; + valid = false; + } + + this.setState(state); - Client.updateTeamDisplayName(data, - function nameChangeSuccess() { + if (!valid) { + return; + } + + var data = this.props.team; + data.invite_id = this.state.invite_id; + Client.updateTeam(data, + (team) => { + TeamStore.saveTeam(team); + TeamStore.emitChange(); this.props.updateSection(''); - $('#team_settings').modal('hide'); - window.location.reload(); - }.bind(this), - function nameChangeFail(err) { + }, + (err) => { state.serverError = err.message; this.setState(state); - }.bind(this) + } ); } + componentWillReceiveProps(newProps) { if (newProps.team && newProps.teamDisplayName) { this.setState({name: newProps.teamDisplayName}); } } + handleClose() { this.setState({clientError: '', serverError: ''}); this.props.updateSection(''); } + componentDidMount() { $('#team_settings').on('hidden.bs.modal', this.handleClose); } + componentWillUnmount() { $('#team_settings').off('hidden.bs.modal', this.handleClose); } - onUpdateSection(e) { + + onUpdateNameSection(e) { e.preventDefault(); if (this.props.activeSection === 'name') { this.props.updateSection(''); @@ -79,10 +200,44 @@ export default class GeneralTab extends React.Component { this.props.updateSection('name'); } } + + onUpdateInviteIdSection(e) { + e.preventDefault(); + if (this.props.activeSection === 'invite_id') { + this.props.updateSection(''); + } else { + this.props.updateSection('invite_id'); + } + } + + onUpdateOpenInviteSection(e) { + e.preventDefault(); + if (this.props.activeSection === 'open_invite') { + this.props.updateSection(''); + } else { + this.props.updateSection('open_invite'); + } + } + + onUpdateTeamListingSection(e) { + e.preventDefault(); + if (this.props.activeSection === 'team_listing') { + this.props.updateSection(''); + } else { + this.props.updateSection('team_listing'); + } + } + updateName(e) { e.preventDefault(); this.setState({name: e.target.value}); } + + updateInviteId(e) { + e.preventDefault(); + this.setState({invite_id: e.target.value}); + } + render() { let clientError = null; let serverError = null; @@ -93,10 +248,179 @@ export default class GeneralTab extends React.Component { serverError = this.state.serverError; } + let teamListingSection; + if (this.props.activeSection === 'team_listing') { + const inputs = [ + <div key='userTeamListingOptions'> + <div className='radio'> + <label> + <input + name='userTeamListingOptions' + type='radio' + defaultChecked={this.state.allow_team_listing} + onChange={this.handleTeamListingRadio.bind(this, true)} + /> + {'Yes'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + ref='teamListingRadioNo' + name='userTeamListingOptions' + type='radio' + defaultChecked={!this.state.allow_team_listing} + onChange={this.handleTeamListingRadio.bind(this, false)} + /> + {'No'} + </label> + <br/> + </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='Include this team in the Team Directory' + inputs={inputs} + submit={this.handleTeamListingSubmit} + server_error={serverError} + client_error={clientError} + updateSection={this.onUpdateTeamListingSection} + /> + ); + } else { + let describe = ''; + if (this.state.allow_team_listing === true) { + describe = 'Yes'; + } else { + describe = 'No'; + } + + teamListingSection = ( + <SettingItemMin + title='Include this team in the Team Directory' + describe={describe} + updateSection={this.onUpdateTeamListingSection} + /> + ); + } + + let openInviteSection; + if (this.props.activeSection === 'open_invite') { + const inputs = [ + <div key='userOpenInviteOptions'> + <div className='radio'> + <label> + <input + name='userOpenInviteOptions' + type='radio' + defaultChecked={this.state.allow_open_invite} + onChange={this.handleOpenInviteRadio.bind(this, true)} + /> + {'Yes'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + name='userOpenInviteOptions' + type='radio' + defaultChecked={!this.state.allow_open_invite} + onChange={this.handleOpenInviteRadio.bind(this, false)} + /> + {'No'} + </label> + <br/> + </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 anyone to sign-up from login page' + inputs={inputs} + submit={this.handleOpenInviteSubmit} + server_error={serverError} + updateSection={this.onUpdateOpenInviteSection} + /> + ); + } else { + let describe = ''; + if (this.state.allow_open_invite === true) { + describe = 'Yes'; + } else { + describe = 'No'; + } + + openInviteSection = ( + <SettingItemMin + title='Allow anyone to sign-up from login page' + describe={describe} + updateSection={this.onUpdateOpenInviteSection} + /> + ); + } + + let inviteSection; + + if (this.props.activeSection === 'invite_id') { + const inputs = []; + + inputs.push( + <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> + ); + + inviteSection = ( + <SettingItemMax + title={`Invite Code`} + inputs={inputs} + submit={this.handleInviteIdSubmit} + server_error={serverError} + client_error={clientError} + updateSection={this.onUpdateInviteIdSection} + /> + ); + } else { + inviteSection = ( + <SettingItemMin + title={`Invite Code`} + describe={`Click 'Edit' to regenerate Invite Code.`} + updateSection={this.onUpdateInviteIdSection} + /> + ); + } + let nameSection; if (this.props.activeSection === 'name') { - let inputs = []; + const inputs = []; let teamNameLabel = 'Team Name'; if (Utils.isMobile()) { @@ -127,17 +451,17 @@ export default class GeneralTab extends React.Component { submit={this.handleNameSubmit} server_error={serverError} client_error={clientError} - updateSection={this.onUpdateSection} + updateSection={this.onUpdateNameSection} /> ); } else { - let describe = this.state.name; + var describe = this.state.name; nameSection = ( <SettingItemMin title={`Team Name`} describe={describe} - updateSection={this.onUpdateSection} + updateSection={this.onUpdateNameSection} /> ); } @@ -158,16 +482,22 @@ export default class GeneralTab extends React.Component { ref='title' > <i className='modal-back'></i> - General Settings + {'General Settings'} </h4> </div> <div ref='wrapper' className='user-settings' > - <h3 className='tab-header'>General Settings</h3> + <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> </div> @@ -178,6 +508,5 @@ export default class GeneralTab extends React.Component { GeneralTab.propTypes = { updateSection: React.PropTypes.func.isRequired, team: React.PropTypes.object.isRequired, - activeSection: React.PropTypes.string.isRequired, - teamDisplayName: React.PropTypes.string.isRequired + activeSection: React.PropTypes.string.isRequired }; diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx index e14da4f04..09674f1ef 100644 --- a/web/react/components/team_settings.jsx +++ b/web/react/components/team_settings.jsx @@ -37,7 +37,6 @@ export default class TeamSettings extends React.Component { team={this.state.team} activeSection={this.props.activeSection} updateSection={this.props.updateSection} - teamDisplayName={this.props.teamDisplayName} /> </div> ); @@ -72,12 +71,11 @@ export default class TeamSettings extends React.Component { TeamSettings.defaultProps = { activeTab: '', - activeSection: '', - teamDisplayName: '' + activeSection: '' }; + TeamSettings.propTypes = { activeTab: React.PropTypes.string.isRequired, activeSection: React.PropTypes.string.isRequired, - updateSection: React.PropTypes.func.isRequired, - teamDisplayName: React.PropTypes.string.isRequired + updateSection: React.PropTypes.func.isRequired }; diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index 5c5995020..17fe31c65 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -82,7 +82,6 @@ export default class TeamSettingsModal extends React.Component { activeTab={this.state.activeTab} activeSection={this.state.activeSection} updateSection={this.updateSection} - teamDisplayName={this.props.teamDisplayName} /> </div> </div> @@ -95,5 +94,4 @@ export default class TeamSettingsModal extends React.Component { } TeamSettingsModal.propTypes = { - teamDisplayName: React.PropTypes.string.isRequired }; diff --git a/web/react/components/team_signup_display_name_page.jsx b/web/react/components/team_signup_display_name_page.jsx index 4d08274e4..2005ecc31 100644 --- a/web/react/components/team_signup_display_name_page.jsx +++ b/web/react/components/team_signup_display_name_page.jsx @@ -25,6 +25,9 @@ export default class TeamSignupDisplayNamePage extends React.Component { if (!displayName) { this.setState({nameError: 'This field is required'}); return; + } else if (displayName.length < 4 || displayName.length > 15) { + this.setState({nameError: 'Name must be 4 or more characters up to a maximum of 15'}); + return; } this.props.state.wizard = 'team_url'; diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx index 02d5cab8e..8972fda1a 100644 --- a/web/react/components/team_signup_url_page.jsx +++ b/web/react/components/team_signup_url_page.jsx @@ -35,8 +35,8 @@ export default class TeamSignupUrlPage extends React.Component { if (cleanedName !== name || !urlRegex.test(name)) { this.setState({nameError: "Use only lower case letters, numbers and dashes. Must start with a letter and can't end in a dash."}); return; - } else if (cleanedName.length <= 2 || cleanedName.length > 15) { - this.setState({nameError: 'Name must be 3 or more characters up to a maximum of 15'}); + } else if (cleanedName.length < 4 || cleanedName.length > 15) { + this.setState({nameError: 'Name must be 4 or more characters up to a maximum of 15'}); return; } diff --git a/web/react/components/team_signup_with_email.jsx b/web/react/components/team_signup_with_email.jsx index ff4ccd4d8..021713f04 100644 --- a/web/react/components/team_signup_with_email.jsx +++ b/web/react/components/team_signup_with_email.jsx @@ -71,7 +71,7 @@ export default class EmailSignUpPage extends React.Component { className='btn btn-md btn-primary' type='submit' > - {'Sign up'} + {'Create Team'} </button> {serverError} </div> diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index 86bb42f62..707033d8f 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -2,7 +2,7 @@ // See License.txt for license information. const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const PostStore = require('../stores/post_store.jsx'); +const SearchStore = require('../stores/search_store.jsx'); const CommandList = require('./command_list.jsx'); const ErrorStore = require('../stores/error_store.jsx'); @@ -54,7 +54,7 @@ export default class Textbox extends React.Component { } componentDidMount() { - PostStore.addAddMentionListener(this.onListenerChange); + SearchStore.addAddMentionListener(this.onListenerChange); ErrorStore.addChangeListener(this.onRecievedError); this.resize(); @@ -62,7 +62,7 @@ export default class Textbox extends React.Component { } componentWillUnmount() { - PostStore.removeAddMentionListener(this.onListenerChange); + SearchStore.removeAddMentionListener(this.onListenerChange); ErrorStore.removeChangeListener(this.onRecievedError); } 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..8fc38b1e3 --- /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 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 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/components/user_settings/code_theme_chooser.jsx b/web/react/components/user_settings/code_theme_chooser.jsx new file mode 100644 index 000000000..eef4b24ba --- /dev/null +++ b/web/react/components/user_settings/code_theme_chooser.jsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +var Constants = require('../../utils/constants.jsx'); + +export default class CodeThemeChooser extends React.Component { + constructor(props) { + super(props); + this.state = {}; + } + render() { + const theme = this.props.theme; + + const premadeThemes = []; + for (const k in Constants.CODE_THEMES) { + if (Constants.CODE_THEMES.hasOwnProperty(k)) { + let activeClass = ''; + if (k === theme.codeTheme) { + activeClass = 'active'; + } + + premadeThemes.push( + <div + className='col-xs-6 col-sm-3 premade-themes' + key={'premade-theme-key' + k} + > + <div + className={activeClass} + onClick={() => this.props.updateTheme(k)} + > + <label> + <img + className='img-responsive' + src={'/static/images/themes/code_themes/' + k + '.png'} + /> + <div className='theme-label'>{Constants.CODE_THEMES[k]}</div> + </label> + </div> + </div> + ); + } + } + + return ( + <div className='row'> + {premadeThemes} + </div> + ); + } +} + +CodeThemeChooser.propTypes = { + theme: React.PropTypes.object.isRequired, + updateTheme: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx index 44b3f4544..095e5b622 100644 --- a/web/react/components/user_settings/custom_theme_chooser.jsx +++ b/web/react/components/user_settings/custom_theme_chooser.jsx @@ -40,11 +40,12 @@ export default class CustomThemeChooser extends React.Component { const theme = {type: 'custom'}; let index = 0; Constants.THEME_ELEMENTS.forEach((element) => { - if (index < colors.length) { + if (index < colors.length - 1) { theme[element.id] = colors[index]; } index++; }); + theme.codeTheme = colors[colors.length - 1]; this.props.updateTheme(theme); } @@ -78,6 +79,8 @@ export default class CustomThemeChooser extends React.Component { colors += theme[element.id] + ','; }); + colors += theme.codeTheme; + const pasteBox = ( <div className='col-sm-12'> <label className='custom-label'> diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx index 6e9b2205d..93be988d1 100644 --- a/web/react/components/user_settings/manage_outgoing_hooks.jsx +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -1,11 +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() { @@ -45,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}); } ); } @@ -75,7 +76,7 @@ export default class ManageOutgoingHooks extends React.Component { this.setState({hooks}); }, (err) => { - this.setState({serverError: err}); + this.setState({editError: err.message}); } ); } @@ -94,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}); } ); } @@ -105,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}); } ); } @@ -123,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(); @@ -235,7 +240,9 @@ 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'> <div> <label className='control-label'>{'Channel'}</label> @@ -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.jsx b/web/react/components/user_settings/user_settings.jsx index 15bf961d6..546e26ca3 100644 --- a/web/react/components/user_settings/user_settings.jsx +++ b/web/react/components/user_settings/user_settings.jsx @@ -10,6 +10,7 @@ var AppearanceTab = require('./user_settings_appearance.jsx'); var DeveloperTab = require('./user_settings_developer.jsx'); var IntegrationsTab = require('./user_settings_integrations.jsx'); var DisplayTab = require('./user_settings_display.jsx'); +var AdvancedTab = require('./user_settings_advanced.jsx'); export default class UserSettings extends React.Component { constructor(props) { @@ -110,6 +111,17 @@ export default class UserSettings extends React.Component { /> </div> ); + } else if (this.props.activeTab === 'advanced') { + return ( + <div> + <AdvancedTab + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + /> + </div> + ); } return <div/>; diff --git a/web/react/components/user_settings/user_settings_advanced.jsx b/web/react/components/user_settings/user_settings_advanced.jsx new file mode 100644 index 000000000..910444735 --- /dev/null +++ b/web/react/components/user_settings/user_settings_advanced.jsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const Client = require('../../utils/client.jsx'); +const SettingItemMin = require('../setting_item_min.jsx'); +const SettingItemMax = require('../setting_item_max.jsx'); +const Constants = require('../../utils/constants.jsx'); +const PreferenceStore = require('../../stores/preference_store.jsx'); + +export default class AdvancedSettingsDisplay extends React.Component { + constructor(props) { + super(props); + + this.updateSection = this.updateSection.bind(this); + this.updateSetting = this.updateSetting.bind(this); + this.handleClose = this.handleClose.bind(this); + this.setupInitialState = this.setupInitialState.bind(this); + + this.state = this.setupInitialState(); + } + + setupInitialState() { + const sendOnCtrlEnter = PreferenceStore.getPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + {value: 'false'} + ).value; + + return { + settings: {send_on_ctrl_enter: sendOnCtrlEnter} + }; + } + + updateSetting(setting, value) { + const settings = this.state.settings; + settings[setting] = value; + this.setState(settings); + } + + handleSubmit(setting) { + const preference = PreferenceStore.setPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + setting, + this.state.settings[setting] + ); + + Client.savePreferences([preference], + () => { + PreferenceStore.emitChange(); + this.updateSection(''); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + updateSection(section) { + this.props.updateSection(section); + } + + handleClose() { + this.updateSection(''); + } + + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + } + + render() { + const serverError = this.state.serverError || null; + let ctrlSendSection; + + if (this.props.activeSection === 'advancedCtrlSend') { + const ctrlSendActive = [ + this.state.settings.send_on_ctrl_enter === 'true', + this.state.settings.send_on_ctrl_enter === 'false' + ]; + + const inputs = [ + <div key='ctrlSendSetting'> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[0]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'true')} + /> + {'On'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[1]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'false')} + /> + {'Off'} + </label> + <br/> + </div> + <div><br/>{'If enabled \'Enter\' inserts a new line and \'Ctrl + Enter\' submits the message.'}</div> + </div> + ]; + + ctrlSendSection = ( + <SettingItemMax + title='Send messages on Ctrl + Enter' + inputs={inputs} + submit={() => this.handleSubmit('send_on_ctrl_enter')} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + ctrlSendSection = ( + <SettingItemMin + title='Send messages on Ctrl + Enter' + describe={this.state.settings.send_on_ctrl_enter === 'true' ? 'On' : 'Off'} + updateSection={() => this.props.updateSection('advancedCtrlSend')} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <i className='modal-back'></i> + {'Advanced Settings'} + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'>{'Advanced Settings'}</h3> + <div className='divider-dark first'/> + {ctrlSendSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +AdvancedSettingsDisplay.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index 8c62a189d..7b4b54e27 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -7,6 +7,7 @@ var Utils = require('../../utils/utils.jsx'); const CustomThemeChooser = require('./custom_theme_chooser.jsx'); const PremadeThemeChooser = require('./premade_theme_chooser.jsx'); +const CodeThemeChooser = require('./code_theme_chooser.jsx'); const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx'); const Constants = require('../../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; @@ -18,12 +19,14 @@ export default class UserSettingsAppearance extends React.Component { this.onChange = this.onChange.bind(this); this.submitTheme = this.submitTheme.bind(this); this.updateTheme = this.updateTheme.bind(this); + this.updateCodeTheme = this.updateCodeTheme.bind(this); this.handleClose = this.handleClose.bind(this); this.handleImportModal = this.handleImportModal.bind(this); this.state = this.getStateFromStores(); this.originalTheme = this.state.theme; + this.originalCodeTheme = this.state.theme.codeTheme; } componentDidMount() { UserStore.addChangeListener(this.onChange); @@ -58,6 +61,10 @@ export default class UserSettingsAppearance extends React.Component { type = 'custom'; } + if (!theme.codeTheme) { + theme.codeTheme = Constants.DEFAULT_CODE_THEME; + } + return {theme, type}; } onChange() { @@ -93,6 +100,15 @@ export default class UserSettingsAppearance extends React.Component { ); } updateTheme(theme) { + if (!theme.codeTheme) { + theme.codeTheme = this.state.theme.codeTheme; + } + this.setState({theme}); + Utils.applyTheme(theme); + } + updateCodeTheme(codeTheme) { + var theme = this.state.theme; + theme.codeTheme = codeTheme; this.setState({theme}); Utils.applyTheme(theme); } @@ -102,6 +118,7 @@ export default class UserSettingsAppearance extends React.Component { handleClose() { const state = this.getStateFromStores(); state.serverError = null; + state.theme.codeTheme = this.originalCodeTheme; Utils.applyTheme(state.theme); @@ -170,7 +187,13 @@ export default class UserSettingsAppearance extends React.Component { </div> {custom} <hr /> - {serverError} + <strong className='radio'>{'Code Theme'}</strong> + <CodeThemeChooser + theme={this.state.theme} + updateTheme={this.updateCodeTheme} + /> + <hr /> + {serverError} <a className='btn btn-sm btn-primary' href='#' 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_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 70e559c30..3adac197a 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -171,7 +171,7 @@ export default class UserSettingsGeneralTab extends React.Component { }.bind(this), function imageUploadFailure(err) { var state = this.setupInitialState(this.props); - state.serverError = err; + state.serverError = err.message; this.setState(state); }.bind(this) ); @@ -570,6 +570,7 @@ export default class UserSettingsGeneralTab extends React.Component { /> ); } + return ( <div> <div className='modal-header'> diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 4b1e5e532..4a9915a1f 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -43,6 +43,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { incomingHooksSection = ( <SettingItemMax title='Incoming Webhooks' + width='medium' inputs={inputs} updateSection={(e) => { this.updateSection(''); @@ -54,7 +55,8 @@ export default class UserSettingsIntegrationsTab extends React.Component { incomingHooksSection = ( <SettingItemMin title='Incoming Webhooks' - describe='Manage your incoming webhooks (Developer feature)' + width='medium' + describe='Manage your incoming webhooks' updateSection={() => { this.updateSection('incoming-hooks'); }} @@ -72,6 +74,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { outgoingHooksSection = ( <SettingItemMax title='Outgoing Webhooks' + width='medium' inputs={inputs} updateSection={(e) => { this.updateSection(''); @@ -83,6 +86,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { outgoingHooksSection = ( <SettingItemMin title='Outgoing Webhooks' + width='medium' describe='Manage your outgoing webhooks' updateSection={() => { this.updateSection('outgoing-hooks'); diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index 5449ae91e..18dd490e7 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -43,6 +43,7 @@ export default class UserSettingsModal extends React.Component { tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); } tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'}); + tabs.push({name: 'advanced', uiName: 'Advanced', icon: 'glyphicon glyphicon-list-alt'}); return ( <div diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx index 61d49acb2..2b904763c 100644 --- a/web/react/components/user_settings/user_settings_notifications.jsx +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -37,18 +37,18 @@ function getNotificationsStateFromStores() { if (user.notify_props.mention_keys) { var keys = user.notify_props.mention_keys.split(','); - if (keys.indexOf(user.username) !== -1) { + if (keys.indexOf(user.username) === -1) { + usernameKey = false; + } else { usernameKey = true; keys.splice(keys.indexOf(user.username), 1); - } else { - usernameKey = false; } - if (keys.indexOf('@' + user.username) !== -1) { + if (keys.indexOf('@' + user.username) === -1) { + mentionKey = false; + } else { mentionKey = true; keys.splice(keys.indexOf('@' + user.username), 1); - } else { - mentionKey = false; } customKeys = keys.join(','); |