diff options
Diffstat (limited to 'web/react')
90 files changed, 4403 insertions, 1689 deletions
diff --git a/web/react/.eslintrc b/web/react/.eslintrc index 6a35d3123..d78068882 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -20,7 +20,8 @@ "globals": { "React": false, "ReactDOM": false, - "ReactBootstrap": false + "ReactBootstrap": false, + "Chart": false }, "rules": { "comma-dangle": [2, "never"], 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(','); diff --git a/web/react/package.json b/web/react/package.json index e6a662375..9af6f5880 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -6,6 +6,7 @@ "autolinker": "0.18.1", "babel-runtime": "5.8.24", "flux": "2.1.1", + "highlight.js": "^8.9.1", "keymirror": "0.1.1", "marked": "0.3.5", "object-assign": "3.0.0", diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 03e049db0..067dcde50 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -2,13 +2,12 @@ // See License.txt for license information. var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Navbar = require('../components/navbar.jsx'); -var Sidebar = require('../components/sidebar.jsx'); -var ChannelHeader = require('../components/channel_header.jsx'); -var PostListContainer = require('../components/post_list_container.jsx'); -var CreatePost = require('../components/create_post.jsx'); -var SidebarRight = require('../components/sidebar_right.jsx'); -var SidebarRightMenu = require('../components/sidebar_right_menu.jsx'); +var ChannelView = require('../components/channel_view.jsx'); +var ChannelLoader = require('../components/channel_loader.jsx'); +var ErrorBar = require('../components/error_bar.jsx'); +var ErrorStore = require('../stores/error_store.jsx'); + +var MentionList = require('../components/mention_list.jsx'); var GetLinkModal = require('../components/get_link_modal.jsx'); var MemberInviteModal = require('../components/invite_member_modal.jsx'); var EditChannelModal = require('../components/edit_channel_modal.jsx'); @@ -24,15 +23,10 @@ var TeamSettingsModal = require('../components/team_settings_modal.jsx'); var ChannelMembersModal = require('../components/channel_members.jsx'); var ChannelInviteModal = require('../components/channel_invite_modal.jsx'); var TeamMembersModal = require('../components/team_members.jsx'); -var ErrorBar = require('../components/error_bar.jsx'); -var ErrorStore = require('../stores/error_store.jsx'); -var ChannelLoader = require('../components/channel_loader.jsx'); -var MentionList = require('../components/mention_list.jsx'); var ChannelInfoModal = require('../components/channel_info_modal.jsx'); var AccessHistoryModal = require('../components/access_history_modal.jsx'); var ActivityLogModal = require('../components/activity_log_modal.jsx'); var RemovedFromChannelModal = require('../components/removed_from_channel_modal.jsx'); -var FileUploadOverlay = require('../components/file_upload_overlay.jsx'); var RegisterAppModal = require('../components/register_app_modal.jsx'); var ImportThemeModal = require('../components/user_settings/import_theme_modal.jsx'); @@ -61,20 +55,29 @@ function setupChannelPage(props) { ); ReactDOM.render( - <Navbar teamDisplayName={props.TeamDisplayName} />, - document.getElementById('navbar') + <ChannelView/>, + document.getElementById('channel_view') ); ReactDOM.render( - <Sidebar - teamDisplayName={props.TeamDisplayName} - teamName={props.TeamName} - teamType={props.TeamType} - />, - document.getElementById('sidebar-left') + <MentionList id='post_textbox' />, + document.getElementById('post_mention_tab') ); ReactDOM.render( + <MentionList id='reply_textbox' />, + document.getElementById('reply_mention_tab') + ); + + ReactDOM.render( + <MentionList id='edit_textbox' />, + document.getElementById('edit_mention_tab') + ); + + // + // Modals + // + ReactDOM.render( <GetLinkModal />, document.getElementById('get_link_modal') ); @@ -90,7 +93,7 @@ function setupChannelPage(props) { ); ReactDOM.render( - <TeamSettingsModal teamDisplayName={props.TeamDisplayName} />, + <TeamSettingsModal />, document.getElementById('team_settings_modal') ); @@ -105,11 +108,6 @@ function setupChannelPage(props) { ); ReactDOM.render( - <ChannelHeader />, - document.getElementById('channel-header') - ); - - ReactDOM.render( <EditChannelModal />, document.getElementById('edit_channel_modal') ); @@ -150,11 +148,6 @@ function setupChannelPage(props) { ); ReactDOM.render( - <PostListContainer />, - document.getElementById('post-list') - ); - - ReactDOM.render( <EditPostModal />, document.getElementById('edit_post_modal') ); @@ -170,39 +163,6 @@ function setupChannelPage(props) { ); ReactDOM.render( - <CreatePost />, - document.getElementById('post-create') - ); - - ReactDOM.render( - <SidebarRight />, - document.getElementById('sidebar-right') - ); - - ReactDOM.render( - <SidebarRightMenu - teamDisplayName={props.TeamDisplayName} - teamType={props.TeamType} - />, - document.getElementById('sidebar-menu') - ); - - ReactDOM.render( - <MentionList id='post_textbox' />, - document.getElementById('post_mention_tab') - ); - - ReactDOM.render( - <MentionList id='reply_textbox' />, - document.getElementById('reply_mention_tab') - ); - - ReactDOM.render( - <MentionList id='edit_textbox' />, - document.getElementById('edit_mention_tab') - ); - - ReactDOM.render( <AccessHistoryModal />, document.getElementById('access_history_modal') ); @@ -218,13 +178,6 @@ function setupChannelPage(props) { ); ReactDOM.render( - <FileUploadOverlay - overlayType='center' - />, - document.getElementById('file_upload_overlay') - ); - - ReactDOM.render( <RegisterAppModal />, document.getElementById('register_app_modal') ); diff --git a/web/react/pages/login.jsx b/web/react/pages/login.jsx index 430de980c..9865e6fd2 100644 --- a/web/react/pages/login.jsx +++ b/web/react/pages/login.jsx @@ -8,6 +8,7 @@ function setupLoginPage(props) { <Login teamDisplayName={props.TeamDisplayName} teamName={props.TeamName} + inviteId={props.InviteId} />, document.getElementById('login') ); diff --git a/web/react/pages/signup_team.jsx b/web/react/pages/signup_team.jsx index dc8394a77..caa93b5bf 100644 --- a/web/react/pages/signup_team.jsx +++ b/web/react/pages/signup_team.jsx @@ -3,9 +3,19 @@ var SignupTeam = require('../components/signup_team.jsx'); -function setupSignupTeamPage() { +function setupSignupTeamPage(props) { + var teams = []; + + for (var prop in props) { + if (props.hasOwnProperty(prop)) { + if (prop !== 'Title') { + teams.push({name: prop, display_name: props[prop]}); + } + } + } + ReactDOM.render( - <SignupTeam />, + <SignupTeam teams={teams} />, document.getElementById('signup-team') ); } diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 4a9314b31..0fe253310 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -12,12 +12,9 @@ var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; var CHANGE_EVENT = 'change'; -var SEARCH_CHANGE_EVENT = 'search_change'; -var SEARCH_TERM_CHANGE_EVENT = 'search_term_change'; var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; -var MENTION_DATA_CHANGE_EVENT = 'mention_data_change'; -var ADD_MENTION_EVENT = 'add_mention'; var EDIT_POST_EVENT = 'edit_post'; +var POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; class PostStoreClass extends EventEmitter { constructor() { @@ -26,21 +23,19 @@ class PostStoreClass extends EventEmitter { this.emitChange = this.emitChange.bind(this); this.addChangeListener = this.addChangeListener.bind(this); this.removeChangeListener = this.removeChangeListener.bind(this); - this.emitSearchChange = this.emitSearchChange.bind(this); - this.addSearchChangeListener = this.addSearchChangeListener.bind(this); - this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this); - this.emitSearchTermChange = this.emitSearchTermChange.bind(this); - this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this); - this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this); + this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); - this.emitMentionDataChange = this.emitMentionDataChange.bind(this); - this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this); - this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this); - this.emitAddMention = this.emitAddMention.bind(this); - this.addAddMentionListener = this.addAddMentionListener.bind(this); - this.removeAddMentionListener = this.removeAddMentionListener.bind(this); + + this.emitEditPost = this.emitEditPost.bind(this); + this.addEditPostListener = this.addEditPostListener.bind(this); + this.removeEditPostListener = this.removeEditPostListner.bind(this); + + this.emitPostsViewJump = this.emitPostsViewJump.bind(this); + this.addPostsViewJumpListener = this.addPostsViewJumpListener.bind(this); + this.removePostsViewJumpListener = this.removePostsViewJumpListener.bind(this); + this.getCurrentPosts = this.getCurrentPosts.bind(this); this.storePosts = this.storePosts.bind(this); this.pStorePosts = this.pStorePosts.bind(this); @@ -59,13 +54,8 @@ class PostStoreClass extends EventEmitter { this.pRemovePendingPost = this.pRemovePendingPost.bind(this); this.clearPendingPosts = this.clearPendingPosts.bind(this); this.updatePendingPost = this.updatePendingPost.bind(this); - this.storeSearchResults = this.storeSearchResults.bind(this); - this.getSearchResults = this.getSearchResults.bind(this); - this.getIsMentionSearch = this.getIsMentionSearch.bind(this); this.storeSelectedPost = this.storeSelectedPost.bind(this); this.getSelectedPost = this.getSelectedPost.bind(this); - this.storeSearchTerm = this.storeSearchTerm.bind(this); - this.getSearchTerm = this.getSearchTerm.bind(this); this.getEmptyDraft = this.getEmptyDraft.bind(this); this.storeCurrentDraft = this.storeCurrentDraft.bind(this); this.getCurrentDraft = this.getCurrentDraft.bind(this); @@ -77,9 +67,6 @@ class PostStoreClass extends EventEmitter { this.clearCommentDraftUploads = this.clearCommentDraftUploads.bind(this); this.storeLatestUpdate = this.storeLatestUpdate.bind(this); this.getLatestUpdate = this.getLatestUpdate.bind(this); - this.emitEditPost = this.emitEditPost.bind(this); - this.addEditPostListener = this.addEditPostListener.bind(this); - this.removeEditPostListener = this.removeEditPostListener.bind(this); this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this); } emitChange() { @@ -94,30 +81,6 @@ class PostStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT, callback); } - emitSearchChange() { - this.emit(SEARCH_CHANGE_EVENT); - } - - addSearchChangeListener(callback) { - this.on(SEARCH_CHANGE_EVENT, callback); - } - - removeSearchChangeListener(callback) { - this.removeListener(SEARCH_CHANGE_EVENT, callback); - } - - emitSearchTermChange(doSearch, isMentionSearch) { - this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch); - } - - addSearchTermChangeListener(callback) { - this.on(SEARCH_TERM_CHANGE_EVENT, callback); - } - - removeSearchTermChangeListener(callback) { - this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback); - } - emitSelectedPostChange(fromSearch) { this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); } @@ -130,40 +93,40 @@ class PostStoreClass extends EventEmitter { this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); } - emitMentionDataChange(id, mentionText) { - this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText); + emitEditPost(post) { + this.emit(EDIT_POST_EVENT, post); } - addMentionDataChangeListener(callback) { - this.on(MENTION_DATA_CHANGE_EVENT, callback); + addEditPostListener(callback) { + this.on(EDIT_POST_EVENT, callback); } - removeMentionDataChangeListener(callback) { - this.removeListener(MENTION_DATA_CHANGE_EVENT, callback); + removeEditPostListner(callback) { + this.removeListener(EDIT_POST_EVENT, callback); } - emitAddMention(id, username) { - this.emit(ADD_MENTION_EVENT, id, username); + emitPostsViewJump(type, post) { + this.emit(POSTS_VIEW_JUMP_EVENT, type, post); } - addAddMentionListener(callback) { - this.on(ADD_MENTION_EVENT, callback); + addPostsViewJumpListener(callback) { + this.on(POSTS_VIEW_JUMP_EVENT, callback); } - removeAddMentionListener(callback) { - this.removeListener(ADD_MENTION_EVENT, callback); + removePostsViewJumpListener(callback) { + this.removeListener(POSTS_VIEW_JUMP_EVENT, callback); } - emitEditPost(post) { - this.emit(EDIT_POST_EVENT, post); + jumpPostsViewToBottom() { + this.emitPostsViewJump(Constants.PostsViewJumpTypes.BOTTOM, null); } - addEditPostListener(callback) { - this.on(EDIT_POST_EVENT, callback); + jumpPostsViewToPost(post) { + this.emitPostsViewJump(Constants.PostsViewJumpTypes.POST, post); } - removeEditPostListener(callback) { - this.removeListener(EDIT_POST_EVENT, callback); + jumpPostsViewSidebarOpen() { + this.emitPostsViewJump(Constants.PostsViewJumpTypes.SIDEBAR_OPEN, null); } getCurrentPosts() { @@ -174,16 +137,16 @@ class PostStoreClass extends EventEmitter { } return null; } - storePosts(channelId, newPostList) { - if (isPostListNull(newPostList)) { + storePosts(channelId, newPostsView) { + if (isPostListNull(newPostsView)) { return; } var postList = makePostListNonNull(this.getPosts(channelId)); - for (let pid in newPostList.posts) { - if (newPostList.posts.hasOwnProperty(pid)) { - var np = newPostList.posts[pid]; + for (const pid in newPostsView.posts) { + if (newPostsView.posts.hasOwnProperty(pid)) { + const np = newPostsView.posts[pid]; if (np.delete_at === 0) { postList.posts[pid] = np; if (postList.order.indexOf(pid) === -1) { @@ -194,7 +157,7 @@ class PostStoreClass extends EventEmitter { delete postList.posts[pid]; } - var index = postList.order.indexOf(pid); + const index = postList.order.indexOf(pid); if (index !== -1) { postList.order.splice(index, 1); } @@ -202,7 +165,7 @@ class PostStoreClass extends EventEmitter { } } - postList.order.sort(function postSort(a, b) { + postList.order.sort((a, b) => { if (postList.posts[a].create_at > postList.posts[b].create_at) { return -1; } @@ -238,14 +201,15 @@ class PostStoreClass extends EventEmitter { var lastPost = null; for (i; i < len; i++) { - if (postList.posts[postList.order[i]].user_id === userId) { + let post = postList.posts[postList.order[i]]; + if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { if (rootId) { - if (postList.posts[postList.order[i]].root_id === rootId || postList.posts[postList.order[i]].id === rootId) { - lastPost = postList.posts[postList.order[i]]; + if (post.root_id === rootId || post.id === rootId) { + lastPost = post; break; } } else { - lastPost = postList.posts[postList.order[i]]; + lastPost = post; break; } } @@ -306,7 +270,7 @@ class PostStoreClass extends EventEmitter { var posts = postList.posts; // sort failed posts to the bottom - postList.order.sort(function postSort(a, b) { + postList.order.sort((a, b) => { if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) { return 1; } @@ -371,7 +335,7 @@ class PostStoreClass extends EventEmitter { this.pStorePendingPosts(channelId, postList); } clearPendingPosts() { - BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', function clearPending(key) { + BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', (key) => { BrowserStore.removeItem(key); }); } @@ -387,28 +351,12 @@ class PostStoreClass extends EventEmitter { this.pStorePendingPosts(post.channel_id, postList); this.emitChange(); } - storeSearchResults(results, isMentionSearch) { - BrowserStore.setItem('search_results', results); - BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch)); - } - getSearchResults() { - return BrowserStore.getItem('search_results'); - } - getIsMentionSearch() { - return BrowserStore.getItem('is_mention_search'); - } storeSelectedPost(postList) { BrowserStore.setItem('select_post', postList); } getSelectedPost() { return BrowserStore.getItem('select_post'); } - storeSearchTerm(term) { - BrowserStore.setItem('search_term', term); - } - getSearchTerm() { - return BrowserStore.getItem('search_term'); - } getEmptyDraft() { return {message: '', uploadsInProgress: [], previews: []}; } @@ -433,7 +381,7 @@ class PostStoreClass extends EventEmitter { return BrowserStore.getGlobalItem('comment_draft_' + parentPostId, this.getEmptyDraft()); } clearDraftUploads() { - BrowserStore.actionOnGlobalItemsWithPrefix('draft_', function clearUploads(key, value) { + BrowserStore.actionOnGlobalItemsWithPrefix('draft_', (key, value) => { if (value) { value.uploadsInProgress = []; BrowserStore.setItem(key, value); @@ -441,7 +389,7 @@ class PostStoreClass extends EventEmitter { }); } clearCommentDraftUploads() { - BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', function clearUploads(key, value) { + BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', (key, value) => { if (value) { value.uploadsInProgress = []; BrowserStore.setItem(key, value); @@ -458,7 +406,7 @@ class PostStoreClass extends EventEmitter { var PostStore = new PostStoreClass(); -PostStore.dispatchToken = AppDispatcher.register(function registry(payload) { +PostStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { @@ -469,24 +417,10 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) { PostStore.pStorePost(action.post); PostStore.emitChange(); break; - case ActionTypes.RECIEVED_SEARCH: - PostStore.storeSearchResults(action.results, action.is_mention_search); - PostStore.emitSearchChange(); - break; - case ActionTypes.RECIEVED_SEARCH_TERM: - PostStore.storeSearchTerm(action.term); - PostStore.emitSearchTermChange(action.do_search, action.is_mention_search); - break; case ActionTypes.RECIEVED_POST_SELECTED: PostStore.storeSelectedPost(action.post_list); PostStore.emitSelectedPostChange(action.from_search); break; - case ActionTypes.RECIEVED_MENTION_DATA: - PostStore.emitMentionDataChange(action.id, action.mention_text); - break; - case ActionTypes.RECIEVED_ADD_MENTION: - PostStore.emitAddMention(action.id, action.username); - break; case ActionTypes.RECIEVED_EDIT_POST: PostStore.emitEditPost(action); break; diff --git a/web/react/stores/search_store.jsx b/web/react/stores/search_store.jsx new file mode 100644 index 000000000..95f0ea845 --- /dev/null +++ b/web/react/stores/search_store.jsx @@ -0,0 +1,153 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +var EventEmitter = require('events').EventEmitter; + +var BrowserStore = require('../stores/browser_store.jsx'); + +var Constants = require('../utils/constants.jsx'); +var ActionTypes = Constants.ActionTypes; + +var CHANGE_EVENT = 'change'; +var SEARCH_CHANGE_EVENT = 'search_change'; +var SEARCH_TERM_CHANGE_EVENT = 'search_term_change'; +var MENTION_DATA_CHANGE_EVENT = 'mention_data_change'; +var ADD_MENTION_EVENT = 'add_mention'; + +class SearchStoreClass extends EventEmitter { + constructor() { + super(); + + this.emitChange = this.emitChange.bind(this); + this.addChangeListener = this.addChangeListener.bind(this); + this.removeChangeListener = this.removeChangeListener.bind(this); + + this.emitSearchChange = this.emitSearchChange.bind(this); + this.addSearchChangeListener = this.addSearchChangeListener.bind(this); + this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this); + + this.emitSearchTermChange = this.emitSearchTermChange.bind(this); + this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this); + this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this); + + this.emitMentionDataChange = this.emitMentionDataChange.bind(this); + this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this); + this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this); + + this.getSearchResults = this.getSearchResults.bind(this); + this.getIsMentionSearch = this.getIsMentionSearch.bind(this); + + this.storeSearchTerm = this.storeSearchTerm.bind(this); + this.getSearchTerm = this.getSearchTerm.bind(this); + + this.storeSearchResults = this.storeSearchResults.bind(this); + } + + emitChange() { + this.emit(CHANGE_EVENT); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + + emitSearchChange() { + this.emit(SEARCH_CHANGE_EVENT); + } + + addSearchChangeListener(callback) { + this.on(SEARCH_CHANGE_EVENT, callback); + } + + removeSearchChangeListener(callback) { + this.removeListener(SEARCH_CHANGE_EVENT, callback); + } + + emitSearchTermChange(doSearch, isMentionSearch) { + this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch); + } + + addSearchTermChangeListener(callback) { + this.on(SEARCH_TERM_CHANGE_EVENT, callback); + } + + removeSearchTermChangeListener(callback) { + this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback); + } + + getSearchResults() { + return BrowserStore.getItem('search_results'); + } + + getIsMentionSearch() { + return BrowserStore.getItem('is_mention_search'); + } + + storeSearchTerm(term) { + BrowserStore.setItem('search_term', term); + } + + getSearchTerm() { + return BrowserStore.getItem('search_term'); + } + + emitMentionDataChange(id, mentionText) { + this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText); + } + + addMentionDataChangeListener(callback) { + this.on(MENTION_DATA_CHANGE_EVENT, callback); + } + + removeMentionDataChangeListener(callback) { + this.removeListener(MENTION_DATA_CHANGE_EVENT, callback); + } + + emitAddMention(id, username) { + this.emit(ADD_MENTION_EVENT, id, username); + } + + addAddMentionListener(callback) { + this.on(ADD_MENTION_EVENT, callback); + } + + removeAddMentionListener(callback) { + this.removeListener(ADD_MENTION_EVENT, callback); + } + + storeSearchResults(results, isMentionSearch) { + BrowserStore.setItem('search_results', results); + BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch)); + } +} + +var SearchStore = new SearchStoreClass(); + +SearchStore.dispatchToken = AppDispatcher.register((payload) => { + var action = payload.action; + + switch (action.type) { + case ActionTypes.RECIEVED_SEARCH: + SearchStore.storeSearchResults(action.results, action.is_mention_search); + SearchStore.emitSearchChange(); + break; + case ActionTypes.RECIEVED_SEARCH_TERM: + SearchStore.storeSearchTerm(action.term); + SearchStore.emitSearchTermChange(action.do_search, action.is_mention_search); + break; + case ActionTypes.RECIEVED_MENTION_DATA: + SearchStore.emitMentionDataChange(action.id, action.mention_text); + break; + case ActionTypes.RECIEVED_ADD_MENTION: + SearchStore.emitAddMention(action.id, action.username); + break; + default: + } +}); + +export default SearchStore; diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 9410c1e9c..4efeb7c8f 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -86,7 +86,7 @@ class SocketStoreClass extends EventEmitter { this.failCount = this.failCount + 1; - ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'}); + ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'}); ErrorStore.emitChange(); }; @@ -160,12 +160,12 @@ function handleNewPostEvent(msg) { if (window.isActive) { AsyncClient.updateLastViewedAt(true); } - } else { + } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) { AsyncClient.getChannel(msg.channel_id); } // Send desktop notification - if (UserStore.getCurrentId() !== msg.user_id) { + if (UserStore.getCurrentId() !== msg.user_id || post.props.from_webhook === 'true') { const msgProps = msg.props; let mentions = []; @@ -189,7 +189,9 @@ function handleNewPostEvent(msg) { } let username = 'Someone'; - if (UserStore.hasProfile(msg.user_id)) { + if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { + username = post.props.override_username; + } else if (UserStore.hasProfile(msg.user_id)) { username = UserStore.getProfile(msg.user_id).username; } diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index b1bc71d54..75dd35e3f 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -132,7 +132,7 @@ export function getChannel(id) { callTracker['getChannel' + id] = utils.getTimestamp(); client.getChannel(id, - function getChannelSuccess(data, textStatus, xhr) { + (data, textStatus, xhr) => { callTracker['getChannel' + id] = 0; if (xhr.status === 304 || !data) { @@ -145,7 +145,7 @@ export function getChannel(id) { member: data.member }); }, - function getChannelFailure(err) { + (err) => { callTracker['getChannel' + id] = 0; dispatchError(err, 'getChannel'); } diff --git a/web/react/utils/channel_intro_mssages.jsx b/web/react/utils/channel_intro_mssages.jsx new file mode 100644 index 000000000..b3f868456 --- /dev/null +++ b/web/react/utils/channel_intro_mssages.jsx @@ -0,0 +1,218 @@ + +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const Utils = require('./utils.jsx'); +const UserProfile = require('../components/user_profile.jsx'); +const ChannelStore = require('../stores/channel_store.jsx'); +const Constants = require('../utils/constants.jsx'); +const TeamStore = require('../stores/team_store.jsx'); + +export function createChannelIntroMessage(channel) { + if (channel.type === 'D') { + return createDMIntroMessage(channel); + } else if (ChannelStore.isDefault(channel)) { + return createDefaultIntroMessage(channel); + } else if (channel.name === Constants.OFFTOPIC_CHANNEL) { + return createOffTopicIntroMessage(channel); + } else if (channel.type === 'O' || channel.type === 'P') { + return createStandardIntroMessage(channel); + } +} + +export function createDMIntroMessage(channel) { + var teammate = Utils.getDirectTeammate(channel.id); + + if (teammate) { + var teammateName = teammate.username; + if (teammate.nickname.length > 0) { + teammateName = teammate.nickname; + } + + return ( + <div className='channel-intro'> + <div className='post-profile-img__container channel-intro-img'> + <img + className='post-profile-img' + src={'/api/v1/users/' + teammate.id + '/image?time=' + teammate.update_at + '&' + Utils.getSessionIndex()} + height='50' + width='50' + /> + </div> + <div className='channel-intro-profile'> + <strong> + <UserProfile userId={teammate.id} /> + </strong> + </div> + <p className='channel-intro-text'> + {'This is the start of your direct message history with ' + teammateName + '.'}<br/> + {'Direct messages and files shared here are not shown to people outside this area.'} + </p> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#edit_channel' + data-header={channel.header} + data-title={channel.display_name} + data-channelid={channel.id} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </a> + </div> + ); + } + + return ( + <div className='channel-intro'> + <p className='channel-intro-text'>{'This is the start of your direct message history with this teammate. Direct messages and files shared here are not shown to people outside this area.'}</p> + </div> + ); +} + +export function createOffTopicIntroMessage(channel) { + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> + <p className='channel-intro__content'> + {'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'} + <br/> + </p> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#edit_channel' + data-header={channel.header} + data-title={channel.display_name} + data-channelid={channel.id} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </a> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#channel_invite' + > + <i className='fa fa-user-plus'></i>{'Invite others to this channel'} + </a> + </div> + ); +} + +export function createDefaultIntroMessage(channel) { + const team = TeamStore.getCurrent(); + let inviteModalLink; + if (team.type === Constants.INVITE_TEAM) { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#invite_member' + > + <i className='fa fa-user-plus'></i>{'Invite others to this team'} + </a> + ); + } else { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#get_link' + data-title='Team Invite' + data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} + > + <i className='fa fa-user-plus'></i>{'Invite others to this team'} + </a> + ); + } + + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> + <p className='channel-intro__content'> + <strong>{'Welcome to ' + channel.display_name + '!'}</strong> + <br/><br/> + {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} + </p> + {inviteModalLink} + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#edit_channel' + data-header={channel.header} + data-title={channel.display_name} + data-channelid={channel.id} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </a> + <br/> + </div> + ); +} + +export function createStandardIntroMessage(channel) { + var uiName = channel.display_name; + var creatorName = ''; + + var uiType; + var memberMessage; + if (channel.type === 'P') { + uiType = 'private group'; + memberMessage = ' Only invited members can see this private group.'; + } else { + uiType = 'channel'; + memberMessage = ' Any member can join and read this channel.'; + } + + var createMessage; + if (creatorName === '') { + createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + Utils.displayDate(channel.create_at) + '.'; + } else { + createMessage = ( + <span> + {'This is the start of the '} + <strong>{uiName}</strong> + {' '} + {uiType}{', created by '} + <strong>{creatorName}</strong> + {' on '} + <strong>{Utils.displayDate(channel.create_at)}</strong> + </span> + ); + } + + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'>{'Beginning of ' + uiName}</h4> + <p className='channel-intro__content'> + {createMessage} + {memberMessage} + <br/> + </p> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#edit_channel' + data-header={channel.header} + data-title={channel.display_name} + data-channelid={channel.id} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </a> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#channel_invite' + > + <i className='fa fa-user-plus'></i>{'Invite others to this ' + uiType} + </a> + </div> + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index bc73f3c64..7ce1346f9 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -34,7 +34,7 @@ function handleError(methodName, xhr, status, err) { if (oldError && oldError.connErrorCount) { errorCount += oldError.connErrorCount; - connectError = 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'; + connectError = 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'; } e = {message: connectError, connErrorCount: errorCount}; @@ -328,6 +328,20 @@ export function getConfig(success, error) { }); } +export function getAnalytics(teamId, name, success, error) { + $.ajax({ + url: '/api/v1/admin/analytics/' + teamId + '/' + name, + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('getAnalytics', xhr, status, err); + error(e); + } + }); +} + export function saveConfig(config, success, error) { $.ajax({ url: '/api/v1/admin/save_config', @@ -428,16 +442,16 @@ export function inviteMembers(data, success, error) { track('api', 'api_teams_invite_members'); } -export function updateTeamDisplayName(data, success, error) { +export function updateTeam(team, success, error) { $.ajax({ - url: '/api/v1/teams/update_name', + url: '/api/v1/teams/update', dataType: 'json', contentType: 'application/json', type: 'POST', - data: JSON.stringify(data), + data: JSON.stringify(team), success, - error: function onError(xhr, status, err) { - var e = handleError('updateTeamDisplayName', xhr, status, err); + error: (xhr, status, err) => { + var e = handleError('updateTeam', xhr, status, err); error(e); } }); @@ -575,21 +589,38 @@ export function updateChannel(channel, success, error) { track('api', 'api_channels_update'); } -export function updateChannelDesc(data, success, error) { +export function updateChannelHeader(data, success, error) { + $.ajax({ + url: '/api/v1/channels/update_header', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: function onError(xhr, status, err) { + var e = handleError('updateChannelHeader', xhr, status, err); + error(e); + } + }); + + track('api', 'api_channels_header'); +} + +export function updateChannelPurpose(data, success, error) { $.ajax({ - url: '/api/v1/channels/update_desc', + url: '/api/v1/channels/update_purpose', dataType: 'json', contentType: 'application/json', type: 'POST', data: JSON.stringify(data), success, error: function onError(xhr, status, err) { - var e = handleError('updateChannelDesc', xhr, status, err); + var e = handleError('updateChannelPurpose', xhr, status, err); error(e); } }); - track('api', 'api_channels_desc'); + track('api', 'api_channels_purpose'); } export function updateNotifyProps(data, success, error) { diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 72773bf05..fd64b1554 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -98,6 +98,7 @@ module.exports = { POST_LOADING: 'loading', POST_FAILED: 'failed', POST_DELETED: 'deleted', + POST_TYPE_JOIN_LEAVE: 'join_leave', RESERVED_TEAM_NAMES: [ 'www', 'web', @@ -126,12 +127,15 @@ module.exports = { MAX_DMS: 20, DM_CHANNEL: 'D', OPEN_CHANNEL: 'O', + INVITE_TEAM: 'I', + OPEN_TEAM: 'O', MAX_POST_LEN: 4000, EMOJI_SIZE: 16, ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>", OFFLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path fill='#cccccc' d='M6.002,7.143C5.645,7.363,5.167,7.52,4.502,7.52c-2.493,0-2.5-2.02-2.5-2.02S1.029,5.607,0.775,6.004C0.41,6.577,0.15,7.716,0.049,8.545c-0.025,0.145-0.057,0.537-0.05,0.598c0.162,1.295,2.237,2.321,4.375,2.357c0.043,0.001,0.085,0.001,0.127,0.001c0.043,0,0.084,0,0.127-0.001c1.879-0.023,3.793-0.879,4.263-2h-2.89L6.002,7.143L6.002,7.143z M4.501,5.488c1.372,0,2.483-1.117,2.483-2.494c0-1.378-1.111-2.495-2.483-2.495c-1.371,0-2.481,1.117-2.481,2.495C2.02,4.371,3.13,5.488,4.501,5.488z M7.002,6.5v2h5v-2H7.002z'/></g></g></svg>", MENU_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='4px' height='16px' viewBox='0 0 8 32' enable-background='new 0 0 8 32' xml:space='preserve'> <g> <circle cx='4' cy='4.062' r='4'/> <circle cx='4' cy='16' r='4'/> <circle cx='4' cy='28' r='4'/> </g> </svg>", COMMENT_ICON: "<svg version='1.1' id='Layer_2' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='15px' height='15px' viewBox='1 1.5 15 15' enable-background='new 1 1.5 15 15' xml:space='preserve'> <g> <g> <path fill='#211B1B' d='M14,1.5H3c-1.104,0-2,0.896-2,2v8c0,1.104,0.896,2,2,2h1.628l1.884,3l1.866-3H14c1.104,0,2-0.896,2-2v-8 C16,2.396,15.104,1.5,14,1.5z M15,11.5c0,0.553-0.447,1-1,1H8l-1.493,2l-1.504-1.991L5,12.5H3c-0.552,0-1-0.447-1-1v-8 c0-0.552,0.448-1,1-1h11c0.553,0,1,0.448,1,1V11.5z'/> </g> </g> </svg>", + UPDATE_TYPING_MS: 5000, THEMES: { default: { type: 'Mattermost', @@ -300,9 +304,24 @@ module.exports = { uiName: 'Mention Highlight Link' } ], + CODE_THEMES: { + github: 'GitHub', + solarized_light: 'Solarized light', + monokai: 'Monokai', + solarized_dark: 'Solarized Dark' + }, + DEFAULT_CODE_THEME: 'github', Preferences: { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', - CATEGORY_DISPLAY_SETTINGS: 'display_settings' + CATEGORY_DISPLAY_SETTINGS: 'display_settings', + CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', + TUTORIAL_STEP: 'tutorial_step' + }, + TutorialSteps: { + INTRO_SCREENS: 0, + POST_POPOVER: 1, + CHANNEL_POPOVER: 2, + MENU_POPOVER: 3 }, KeyCodes: { UP: 38, @@ -313,5 +332,35 @@ module.exports = { ENTER: 13, ESCAPE: 27, SPACE: 32 + }, + HighlightedLanguages: { + diff: 'Diff', + apache: 'Apache', + makefile: 'Makefile', + http: 'HTTP', + json: 'JSON', + markdown: 'Markdown', + javascript: 'JavaScript', + css: 'CSS', + nginx: 'nginx', + objectivec: 'Objective-C', + python: 'Python', + xml: 'XML', + perl: 'Perl', + bash: 'Bash', + php: 'PHP', + coffeescript: 'CoffeeScript', + cs: 'C#', + cpp: 'C++', + sql: 'SQL', + go: 'Go', + ruby: 'Ruby', + java: 'Java', + ini: 'ini' + }, + PostsViewJumpTypes: { + BOTTOM: 1, + POST: 2, + SIDEBAR_OPEN: 3 } }; diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 7a4e70054..179416ea0 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -6,7 +6,71 @@ const Utils = require('./utils.jsx'); const marked = require('marked'); -export class MattermostMarkdownRenderer extends marked.Renderer { +const highlightJs = require('highlight.js/lib/highlight.js'); +const highlightJsDiff = require('highlight.js/lib/languages/diff.js'); +const highlightJsApache = require('highlight.js/lib/languages/apache.js'); +const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js'); +const highlightJsHttp = require('highlight.js/lib/languages/http.js'); +const highlightJsJson = require('highlight.js/lib/languages/json.js'); +const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js'); +const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js'); +const highlightJsCss = require('highlight.js/lib/languages/css.js'); +const highlightJsNginx = require('highlight.js/lib/languages/nginx.js'); +const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js'); +const highlightJsPython = require('highlight.js/lib/languages/python.js'); +const highlightJsXml = require('highlight.js/lib/languages/xml.js'); +const highlightJsPerl = require('highlight.js/lib/languages/perl.js'); +const highlightJsBash = require('highlight.js/lib/languages/bash.js'); +const highlightJsPhp = require('highlight.js/lib/languages/php.js'); +const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js'); +const highlightJsCs = require('highlight.js/lib/languages/cs.js'); +const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); +const highlightJsSql = require('highlight.js/lib/languages/sql.js'); +const highlightJsGo = require('highlight.js/lib/languages/go.js'); +const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); +const highlightJsJava = require('highlight.js/lib/languages/java.js'); +const highlightJsIni = require('highlight.js/lib/languages/ini.js'); + +const Constants = require('../utils/constants.jsx'); +const HighlightedLanguages = Constants.HighlightedLanguages; + +class MattermostInlineLexer extends marked.InlineLexer { + constructor(links, options) { + super(links, options); + + this.rules = Object.assign({}, this.rules); + + // modified version of the regex that doesn't break up words in snake_case, + // allows for links starting with www, and allows links succounded by parentheses + // the original is /^[\s\S]+?(?=[\\<!\[_*`~]|https?:\/\/| {2,}\n|$)/ + this.rules.text = /^[\s\S]+?(?=[^\w\/]_|[\\<!\[*`~]|https?:\/\/|www\.|\(| {2,}\n|$)/; + + // modified version of the regex that allows links starting with www and those surrounded + // by parentheses + // the original is /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/ + this.rules.url = /^(\(?(?:https?:\/\/|www\.)[^\s<.][^\s<]*[^<.,:;"'\]\s])/; + + // modified version of the regex that allows <links> starting with www. + // the original is /^<([^ >]+(@|:\/)[^ >]+)>/ + this.rules.autolink = /^<((?:[^ >]+(@|:\/)|www\.)[^ >]+)>/; + } +} + +class MattermostParser extends marked.Parser { + parse(src) { + this.inline = new MattermostInlineLexer(src.links, this.options, this.renderer); + this.tokens = src.reverse(); + + var out = ''; + while (this.next()) { + out += this.tok(); + } + + return out; + } +} + +class MattermostMarkdownRenderer extends marked.Renderer { constructor(options, formattingOptions = {}) { super(options); @@ -15,6 +79,49 @@ export class MattermostMarkdownRenderer extends marked.Renderer { this.text = this.text.bind(this); this.formattingOptions = formattingOptions; + + highlightJs.registerLanguage('diff', highlightJsDiff); + highlightJs.registerLanguage('apache', highlightJsApache); + highlightJs.registerLanguage('makefile', highlightJsMakefile); + highlightJs.registerLanguage('http', highlightJsHttp); + highlightJs.registerLanguage('json', highlightJsJson); + highlightJs.registerLanguage('markdown', highlightJsMarkdown); + highlightJs.registerLanguage('javascript', highlightJsJavascript); + highlightJs.registerLanguage('css', highlightJsCss); + highlightJs.registerLanguage('nginx', highlightJsNginx); + highlightJs.registerLanguage('objectivec', highlightJsObjectivec); + highlightJs.registerLanguage('python', highlightJsPython); + highlightJs.registerLanguage('xml', highlightJsXml); + highlightJs.registerLanguage('perl', highlightJsPerl); + highlightJs.registerLanguage('bash', highlightJsBash); + highlightJs.registerLanguage('php', highlightJsPhp); + highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); + highlightJs.registerLanguage('cs', highlightJsCs); + highlightJs.registerLanguage('cpp', highlightJsCpp); + highlightJs.registerLanguage('sql', highlightJsSql); + highlightJs.registerLanguage('go', highlightJsGo); + highlightJs.registerLanguage('ruby', highlightJsRuby); + highlightJs.registerLanguage('java', highlightJsJava); + highlightJs.registerLanguage('ini', highlightJsIni); + } + + code(code, language) { + let usedLanguage = language; + + if (String(usedLanguage).toLocaleLowerCase() === 'html') { + usedLanguage = 'xml'; + } + + if (!usedLanguage || highlightJs.listLanguages().indexOf(usedLanguage) < 0) { + let parsed = super.code(code, usedLanguage); + return '<div class="post-body--code"><code class="hljs">' + TextFormatting.sanitizeHtml($(parsed).text()) + '</code></div>'; + } + + let parsed = highlightJs.highlight(usedLanguage, code); + return '<div class="post-body--code">' + + '<span class="post-body--code__language">' + HighlightedLanguages[usedLanguage] + '</span>' + + '<code class="hljs">' + parsed.value + '</code>' + + '</div>'; } br() { @@ -32,8 +139,20 @@ export class MattermostMarkdownRenderer extends marked.Renderer { link(href, title, text) { let outHref = href; + let outText = text; + let prefix = ''; + let suffix = ''; + + // some links like https://en.wikipedia.org/wiki/Rendering_(computer_graphics) contain brackets + // and we try our best to differentiate those from ones just wrapped in brackets when autolinking + if (outHref.startsWith('(') && outHref.endsWith(')') && text === outHref) { + prefix = '('; + suffix = ')'; + outText = text.substring(1, text.length - 1); + outHref = outHref.substring(1, outHref.length - 1); + } - if (!(/^(mailto|https?|ftp)/.test(outHref))) { + if (!(/[a-z+.-]+:/i).test(outHref)) { outHref = `http://${outHref}`; } @@ -48,30 +167,37 @@ export class MattermostMarkdownRenderer extends marked.Renderer { output += ' target="_blank">'; } - output += text + '</a>'; + output += outText + '</a>'; - return output; + return prefix + output + suffix; } paragraph(text) { - let outText = text; - - if (!('emoticons' in this.options) || this.options.emoticon) { - outText = TextFormatting.doFormatEmoticons(text); - } - if (this.formattingOptions.singleline) { - return `<p class="markdown__paragraph-inline">${outText}</p>`; + return `<p class="markdown__paragraph-inline">${text}</p>`; } - return super.paragraph(outText); + return super.paragraph(text); } table(header, body) { return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`; } - text(text) { - return TextFormatting.doFormatText(text, this.formattingOptions); + text(txt) { + return TextFormatting.doFormatText(txt, this.formattingOptions); } } + +export function format(text, options) { + const markdownOptions = { + renderer: new MattermostMarkdownRenderer(null, options), + sanitize: true, + gfm: true + }; + + const tokens = marked.lexer(text, markdownOptions); + + return new MattermostParser(markdownOptions).parse(tokens); +} + diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 5c2e68f1e..2de858a17 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -8,8 +8,6 @@ const Markdown = require('./markdown.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('./utils.jsx'); -const marked = require('marked'); - // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and // @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options // as part of the second parameter: @@ -22,11 +20,8 @@ export function formatText(text, options = {}) { let output; if (!('markdown' in options) || options.markdown) { - // the markdown renderer will call doFormatText as necessary so just call marked - output = marked(text, { - renderer: new Markdown.MattermostMarkdownRenderer(null, options), - sanitize: true - }); + // the markdown renderer will call doFormatText as necessary + output = Markdown.format(text, options); } else { output = sanitizeHtml(text); output = doFormatText(output, options); @@ -47,8 +42,8 @@ export function doFormatText(text, options) { const tokens = new Map(); // replace important words and phrases with tokens - output = autolinkUrls(output, tokens); output = autolinkAtMentions(output, tokens); + output = autolinkEmails(output, tokens); output = autolinkHashtags(output, tokens); if (!('emoticons' in options) || options.emoticon) { @@ -78,6 +73,13 @@ export function doFormatEmoticons(text) { return output; } +export function doFormatMentions(text) { + const tokens = new Map(); + let output = autolinkAtMentions(text, tokens); + output = replaceTokens(output, tokens); + return output; +} + export function sanitizeHtml(text) { let output = text; @@ -91,27 +93,21 @@ export function sanitizeHtml(text) { return output; } -function autolinkUrls(text, tokens) { - function replaceUrlWithToken(autolinker, match) { +// Convert emails into tokens +function autolinkEmails(text, tokens) { + function replaceEmailWithToken(autolinker, match) { const linkText = match.getMatchedText(); let url = linkText; if (match.getType() === 'email') { url = `mailto:${url}`; - } else if (!(/^(mailto|https?|ftp)/.test(url))) { - url = `http://${url}`; } const index = tokens.size; - const alias = `MM_LINK${index}`; - - var target = 'target="_blank"'; - if (url.lastIndexOf(Utils.getTeamURLFromAddressBar(), 0) === 0) { - target = ''; - } + const alias = `MM_EMAIL${index}`; tokens.set(alias, { - value: `<a class="theme" ${target} href="${url}">${linkText}</a>`, + value: `<a class="theme" href="${url}">${linkText}</a>`, originalText: linkText }); @@ -120,38 +116,73 @@ function autolinkUrls(text, tokens) { // we can't just use a static autolinker because we need to set replaceFn const autolinker = new Autolinker({ - urls: true, + urls: false, email: true, phone: false, twitter: false, hashtag: false, - replaceFn: replaceUrlWithToken + replaceFn: replaceEmailWithToken }); return autolinker.link(text); } function autolinkAtMentions(text, tokens) { - let output = text; + // Return true if provided character is punctuation + function isPunctuation(character) { + const re = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]/g; + return re.test(character); + } + + // Test if provided text needs to be highlighted, special mention or current user + function mentionExists(u) { + return (Constants.SPECIAL_MENTIONS.indexOf(u) !== -1 || UserStore.getProfileByUsername(u)); + } + + function addToken(username, mention, extraText) { + const index = tokens.size; + const alias = `MM_ATMENTION${index}`; + + tokens.set(alias, { + value: `<a class='mention-link' href='#' data-mention='${username}'>${mention}</a>`, + originalText: mention, + extraText + }); + return alias; + } function replaceAtMentionWithToken(fullMatch, prefix, mention, username) { - const usernameLower = username.toLowerCase(); - if (Constants.SPECIAL_MENTIONS.indexOf(usernameLower) !== -1 || UserStore.getProfileByUsername(usernameLower)) { - const index = tokens.size; - const alias = `MM_ATMENTION${index}`; - - tokens.set(alias, { - value: `<a class='mention-link' href='#' data-mention='${usernameLower}'>${mention}</a>`, - originalText: mention - }); + let usernameLower = username.toLowerCase(); + if (mentionExists(usernameLower)) { + // Exact match + const alias = addToken(usernameLower, mention, ''); return prefix + alias; } + // Not an exact match, attempt to truncate any punctuation to see if we can find a user + const originalUsername = usernameLower; + + for (let c = usernameLower.length; c > 0; c--) { + if (isPunctuation(usernameLower[c - 1])) { + usernameLower = usernameLower.substring(0, c - 1); + + if (mentionExists(usernameLower)) { + const extraText = originalUsername.substr(c - 1); + const alias = addToken(usernameLower, '@' + usernameLower, extraText); + return prefix + alias; + } + } else { + // If the last character is not punctuation, no point in going any further + break; + } + } + return fullMatch; } - output = output.replace(/(^|\s)(@([a-z0-9.\-_]*[a-z0-9]))/gi, replaceAtMentionWithToken); + let output = text; + output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); return output; } @@ -169,10 +200,9 @@ function highlightCurrentMentions(text, tokens) { const newAlias = `MM_SELFMENTION${index}`; newTokens.set(newAlias, { - value: `<span class='mention-highlight'>${alias}</span>`, + value: `<span class='mention-highlight'>${alias}</span>` + token.extraText, originalText: token.originalText }); - output = output.replace(alias, newAlias); } } @@ -246,7 +276,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { var newTokens = new Map(); for (const [alias, token] of tokens) { - if (token.originalText === searchTerm) { + if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) { const index = tokens.size + newTokens.size; const newAlias = `MM_SEARCHTERM${index}`; @@ -276,7 +306,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { return prefix + alias; } - return output.replace(new RegExp(`(^|\\W)(${searchTerm})\\b`, 'gi'), replaceSearchTermWithToken); + return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken); } function replaceTokens(text, tokens) { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 67a9d6983..c82bd1065 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -8,6 +8,7 @@ var PreferenceStore = require('../stores/preference_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; +var Client = require('./client.jsx'); var AsyncClient = require('./async_client.jsx'); var client = require('./client.jsx'); var Autolinker = require('autolinker'); @@ -20,6 +21,7 @@ export function isEmail(email) { export function cleanUpUrlable(input) { var cleaned = input.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-'); + cleaned = cleaned.replace(/-{2,}/, '-'); cleaned = cleaned.replace(/^\-+/, ''); cleaned = cleaned.replace(/\-+$/, ''); return cleaned; @@ -57,6 +59,20 @@ export function isTestDomain() { return false; } +export function isChrome() { + if (navigator.userAgent.indexOf('Chrome') > -1) { + return true; + } + return false; +} + +export function isSafari() { + if (navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1) { + return true; + } + return false; +} + export function isInRole(roles, inRole) { var parts = roles.split(' '); for (var i = 0; i < parts.length; i++) { @@ -209,11 +225,15 @@ export function displayDateTime(ticks) { } interval = Math.floor(seconds / 60); - if (interval > 1) { + if (interval >= 2) { return interval + ' minutes ago'; } - return '1 minute ago'; + if (interval >= 1) { + return '1 minute ago'; + } + + return 'just now'; } export function displayCommentDateTime(ticks) { @@ -225,46 +245,62 @@ export function getTimestamp() { return Date.now(); } -function testUrlMatch(text) { - var urlMatcher = new Autolinker.matchParser.MatchParser({ +// extracts links not styled by Markdown +export function extractLinks(text) { + const urlMatcher = new Autolinker.matchParser.MatchParser({ urls: true, emails: false, twitter: false, phone: false, hashtag: false }); - var result = []; + const links = []; + let replaceText = text; + + // pull out the Markdown code blocks + const codeBlocks = []; + const splitText = replaceText.split('`'); // also handles ``` + for (let i = 1; i < splitText.length; i += 2) { + if (splitText[i].trim() !== '') { + codeBlocks.push(splitText[i]); + } + } + function replaceFn(match) { - var linkData = {}; - var matchText = match.getMatchedText(); + let link = ''; + const matchText = match.getMatchedText(); + const tempText = replaceText; + + const start = replaceText.indexOf(matchText); + const end = start + matchText.length; + + replaceText = replaceText.substring(0, start) + replaceText.substring(end); + + // if it's a Markdown link, just skip it + if (start > 1) { + if (tempText.charAt(start - 2) === ']' && tempText.charAt(start - 1) === '(' && tempText.charAt(end) === ')') { + return; + } + } + + // if it's in a Markdown code block, skip it + for (const i in codeBlocks) { + if (codeBlocks[i].indexOf(matchText) === 0) { + codeBlocks[i] = codeBlocks[i].replace(matchText, ''); + return; + } + } - linkData.text = matchText; if (matchText.trim().indexOf('http') === 0) { - linkData.link = matchText; + link = matchText; } else { - linkData.link = 'http://' + matchText; + link = 'http://' + matchText; } - result.push(linkData); + links.push(link); } urlMatcher.replace(text, replaceFn, this); - return result; -} - -export function extractLinks(text) { - var repRegex = new RegExp('<br>', 'g'); - var matches = testUrlMatch(text.replace(repRegex, '\n')); - - if (!matches.length) { - return {links: null, text: text}; - } - - var links = []; - for (var i = 0; i < matches.length; i++) { - links.push(matches[i].link); - } - - return {links: links, text: text}; + return {links, text}; } export function escapeRegExp(string) { @@ -402,6 +438,11 @@ export function toTitleCase(str) { } export function applyTheme(theme) { + if (!theme.codeTheme) { + theme.codeTheme = Constants.DEFAULT_CODE_THEME; + } + updateCodeTheme(theme.codeTheme); + if (theme.sidebarBg) { changeCss('.sidebar--left, .settings-modal .settings-table .settings-links, .sidebar--menu', 'background:' + theme.sidebarBg, 1); } @@ -432,6 +473,8 @@ export function applyTheme(theme) { if (theme.sidebarTextActiveColor) { changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'color:' + theme.sidebarTextActiveColor, 2); changeCss('.sidebar--left .nav li.active a, .sidebar--left .nav li.active a:hover, .sidebar--left .nav li.active a:focus', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.1), 1); + changeCss('.search-help-popover .search-autocomplete__item:hover', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.05), 1); + changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.15), 1); } if (theme.sidebarHeaderBg) { @@ -471,16 +514,16 @@ export function applyTheme(theme) { changeCss('#post-create', 'background:' + theme.centerChannelBg, 1); changeCss('.date-separator .separator__text, .new-separator .separator__text', 'background:' + theme.centerChannelBg, 1); changeCss('.post-image__column .post-image__details', 'background:' + theme.centerChannelBg, 1); - changeCss('.sidebar--right, .dropdown-menu, .popover', 'background:' + theme.centerChannelBg, 1); + changeCss('.sidebar--right, .dropdown-menu, .popover, .tip-overlay', 'background:' + theme.centerChannelBg, 1); changeCss('.popover.bottom>.arrow:after', 'border-bottom-color:' + theme.centerChannelBg, 1); - changeCss('.popover.right>.arrow:after', 'border-right-color:' + theme.centerChannelBg, 1); + changeCss('.popover.right>.arrow:after, .tip-overlay.tip-overlay--sidebar .arrow, .tip-overlay.tip-overlay--header .arrow', 'border-right-color:' + theme.centerChannelBg, 1); changeCss('.popover.left>.arrow:after', 'border-left-color:' + theme.centerChannelBg, 1); - changeCss('.popover.top>.arrow:after', 'border-top-color:' + theme.centerChannelBg, 1); + changeCss('.popover.top>.arrow:after, .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg, 1); changeCss('.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1); } if (theme.centerChannelColor) { - changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name', 'color:' + theme.centerChannelColor, 1); + changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name, .tip-overlay', 'color:' + theme.centerChannelColor, 1); changeCss('#post-create', 'color:' + theme.centerChannelColor, 2); changeCss('.channel-header__links a', 'fill:' + changeOpacity(theme.centerChannelColor, 0.7), 1); changeCss('.channel-header__links a:hover, .channel-header__links a:active', 'fill:' + theme.centerChannelColor, 2); @@ -490,7 +533,7 @@ export function applyTheme(theme) { changeCss('.dropdown-menu, .popover ', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 3); changeCss('.dropdown-menu, .popover ', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 2); changeCss('.dropdown-menu, .popover ', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 1); - changeCss('.post-body hr, .loading-screen .loading__content .round', 'background:' + theme.centerChannelColor, 1); + changeCss('.post-body hr, .loading-screen .loading__content .round, .tutorial__circles .circle, .tip-overlay .tutorial__circles .circle.active', 'background:' + theme.centerChannelColor, 1); changeCss('.channel-header .heading', 'color:' + theme.centerChannelColor, 1); changeCss('.markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); @@ -539,7 +582,7 @@ export function applyTheme(theme) { } if (theme.buttonBg) { - changeCss('.btn.btn-primary', 'background:' + theme.buttonBg, 1); + changeCss('.btn.btn-primary, .tutorial__circles .circle.active', 'background:' + theme.buttonBg, 1); changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.25), 1); changeCss('.file-playback-controls', 'color:' + changeColor(theme.buttonBg, -0.25), 1); } @@ -588,6 +631,27 @@ export function rgb2hex(rgbIn) { return '#' + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]); } +export function updateCodeTheme(theme) { + const path = '/static/css/highlight/' + theme + '.css'; + const $link = $('link.code_theme'); + if (path !== $link.attr('href')) { + changeCss('code.hljs', 'visibility: hidden'); + var xmlHTTP = new XMLHttpRequest(); + xmlHTTP.open('GET', path, true); + xmlHTTP.onload = function onLoad() { + $link.attr('href', path); + if (isBrowserFirefox()) { + $link.one('load', () => { + changeCss('code.hljs', 'visibility: visible'); + }); + } else { + changeCss('code.hljs', 'visibility: visible'); + } + }; + xmlHTTP.send(); + } +} + export function placeCaretAtEnd(el) { el.focus(); if (typeof window.getSelection != 'undefined' && typeof document.createRange != 'undefined') { @@ -834,6 +898,23 @@ export function getDisplayName(user) { return user.username; } +export function displayUsername(userId) { + const user = UserStore.getProfile(userId); + const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value; + + let username = ''; + if (nameFormat === 'nickname_full_name') { + username = user.nickname || getFullName(user); + } else if (nameFormat === 'full_name') { + username = getFullName(user); + } + if (!username.trim().length) { + username = user.username; + } + + return username; +} + //IE10 does not set window.location.origin automatically so this must be called instead when using it export function getWindowLocationOrigin() { var windowLocationOrigin = window.location.origin; @@ -926,7 +1007,7 @@ export function isBrowserIE() { } export function isBrowserEdge() { - return window.naviagtor && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1; + return window.navigator && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1; } export function getDirectChannelName(id, otherId) { @@ -982,3 +1063,44 @@ export function windowWidth() { export function windowHeight() { return $(window).height(); } + +export function openDirectChannelToUser(user, successCb, errorCb) { + const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id); + let channel = ChannelStore.getByName(channelName); + + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + AsyncClient.savePreferences([preference]); + + if (channel) { + if ($.isFunction(successCb)) { + successCb(channel, true); + } + } else { + channel = { + name: channelName, + last_post_at: 0, + total_msg_count: 0, + type: 'D', + display_name: user.username, + teammate_id: user.id, + status: UserStore.getStatus(user.id) + }; + + Client.createDirectChannel( + channel, + user.id, + (data) => { + AsyncClient.getChannel(data.id); + if ($.isFunction(successCb)) { + successCb(data, false); + } + }, + () => { + window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName; + if ($.isFunction(errorCb)) { + errorCb(); + } + } + ); + } +} |