diff options
Diffstat (limited to 'web/react/components')
19 files changed, 694 insertions, 143 deletions
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index f770d166c..d309ced2e 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -18,6 +18,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) { @@ -149,6 +150,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..f2fb1c96d 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -24,7 +24,7 @@ export default class AdminSidebar extends React.Component { handleClick(name, teamId, e) { e.preventDefault(); this.props.selectTab(name, teamId); - history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`); + history.pushState({name, teamId}, null, `/admin_console/${name}/${teamId || ''}`); } isSelected(name, teamId) { @@ -121,6 +121,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/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..dd8812ad0 --- /dev/null +++ b/web/react/components/admin_console/team_analytics.jsx @@ -0,0 +1,353 @@ +// 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) => { + 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) => { + 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='total-count text-center'> + <div>{'Total Users'}</div> + <div>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div> + </div> + ); + + var openChannelCount = ( + <div className='total-count text-center'> + <div>{'Public Groups'}</div> + <div>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div> + </div> + ); + + var openPrivateCount = ( + <div className='total-count text-center'> + <div>{'Private Groups'}</div> + <div>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div> + </div> + ); + + var postCount = ( + <div className='total-count text-center'> + <div>{'Total Posts'}</div> + <div>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div> + </div> + ); + + var postCountsByDay = ( + <div className='total-count-by-day'> + <div>{'Total Posts'}</div> + <div>{'Loading...'}</div> + </div> + ); + + if (this.state.post_counts_day != null) { + postCountsByDay = ( + <div className='total-count-by-day'> + <div>{'Total Posts'}</div> + <LineChart + data={this.state.post_counts_day} + width='740' + height='225' + /> + </div> + ); + } + + var usersWithPostsByDay = ( + <div className='total-count-by-day'> + <div>{'Total Posts'}</div> + <div>{'Loading...'}</div> + </div> + ); + + if (this.state.user_counts_with_posts_day != null) { + usersWithPostsByDay = ( + <div className='total-count-by-day'> + <div>{'Active Users With Posts'}</div> + <LineChart + data={this.state.user_counts_with_posts_day} + width='740' + height='225' + /> + </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='recent-active-users'> + <div>{'Recent Active Users'}</div> + <table width='90%'> + <tbody> + { + this.state.recent_active_users.map((user) => { + return ( + <tr key={user.id}> + <td className='recent-active-users-td'>{user.email}</td> + <td className='recent-active-users-td'>{Utils.displayDateTime(user.last_activity_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </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='recent-active-users'> + <div>{'Newly Created Users'}</div> + <table width='90%'> + <tbody> + { + this.state.newly_created_users.map((user) => { + return ( + <tr key={user.id}> + <td className='recent-active-users-td'>{user.email}</td> + <td className='recent-active-users-td'>{Utils.displayDateTime(user.create_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + ); + } + + return ( + <div className='wrapper--fixed'> + <h2>{'Statistics for ' + this.props.team.name}</h2> + {serverError} + {totalCount} + {postCount} + {openChannelCount} + {openPrivateCount} + {postCountsByDay} + {usersWithPostsByDay} + {recentActiveUser} + {newUsers} + </div> + ); + } +} + +TeamAnalytics.propTypes = { + team: React.PropTypes.object +}; diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 1b709336f..d66777cc6 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -3,7 +3,7 @@ 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 NavbarSearchBox = require('./search_bar.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); @@ -35,19 +35,19 @@ 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); } componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); - PostStore.removeSearchChangeListener(this.onListenerChange); + SearchStore.removeSearchChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange); } onListenerChange() { diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 435c7d542..18936e808 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -147,7 +147,7 @@ export default class CreateComment extends React.Component { } 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; } diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 055be112d..32ee31efe 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -208,7 +208,7 @@ export default class CreatePost extends React.Component { } 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; } @@ -253,8 +253,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); @@ -265,7 +271,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) { 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/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_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/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 155e88600..9cffa2400 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,36 @@ export default class PopoverListMembers extends React.Component { } }; } + + 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 +78,74 @@ 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 = ( + <button + type='button' + className='btn btn-primary btn-message' + onClick={(e) => this.handleShowDirectChannel(m, e)} + > + {'Message'} + </button> + ); + } + if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { popoverHtml.push( <div className='text--nowrap' key={'popover-member-' + i} > - {m.username} + + <img + className='profile-img pull-left' + width='38' + height='38' + 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 + className='more-description' + > + {details} + </div> + </div> + <div + className='pull-right profile-action' + > + {button} + </div> </div> ); count++; @@ -61,29 +160,37 @@ 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 title='Members' id='member-list-popover' > - {popoverHtml} + <div> + {popoverHtml} + </div> </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/search_bar.jsx b/web/react/components/search_bar.jsx index 0da43e8cd..83c10494a 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,8 +84,8 @@ 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); @@ -150,8 +150,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}); } diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx index 30e15d0ad..ce19c48f0 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,7 +78,7 @@ 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; 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/sidebar.jsx b/web/react/components/sidebar.jsx index ed2c84057..5cb6d168b 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -40,6 +40,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 +51,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(); @@ -192,7 +232,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 + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName; } } onScroll() { @@ -273,6 +316,7 @@ export default class Sidebar extends React.Component { 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 +328,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 +345,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') { @@ -434,6 +470,8 @@ export default class Sidebar extends React.Component { 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; diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx index 4e6985a86..51225cbbe 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 { @@ -22,11 +23,11 @@ export default class SidebarRight extends React.Component { this.state = getStateFromStores(); } componentDidMount() { - PostStore.addSearchChangeListener(this.onSearchChange); + SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); } componentWillUnmount() { - PostStore.removeSearchChangeListener(this.onSearchChange); + SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); } componentDidUpdate() { 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/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 70e559c30..1c8ce3c79 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) ); |