diff options
Diffstat (limited to 'web/react')
29 files changed, 948 insertions, 266 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/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) ); diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 4a9314b31..a58fdde3a 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -12,11 +12,7 @@ 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'; class PostStoreClass extends EventEmitter { @@ -26,21 +22,15 @@ 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.removeEditPostListener.bind(this); + this.getCurrentPosts = this.getCurrentPosts.bind(this); this.storePosts = this.storePosts.bind(this); this.pStorePosts = this.pStorePosts.bind(this); @@ -59,13 +49,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 +62,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 +76,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,30 +88,6 @@ class PostStoreClass extends EventEmitter { this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); } - 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); - } - emitEditPost(post) { this.emit(EDIT_POST_EVENT, post); } @@ -181,9 +115,9 @@ class PostStoreClass extends EventEmitter { var postList = makePostListNonNull(this.getPosts(channelId)); - for (let pid in newPostList.posts) { + for (const pid in newPostList.posts) { if (newPostList.posts.hasOwnProperty(pid)) { - var np = newPostList.posts[pid]; + const np = newPostList.posts[pid]; if (np.delete_at === 0) { postList.posts[pid] = np; if (postList.order.indexOf(pid) === -1) { @@ -194,7 +128,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 +136,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; } @@ -306,7 +240,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 +305,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 +321,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 +351,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 +359,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 +376,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 +387,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..4d69a6716 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,7 +160,7 @@ 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); } 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/client.jsx b/web/react/utils/client.jsx index bc73f3c64..bf117b3b3 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', diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index c20d84f40..0e89b9470 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', @@ -132,6 +133,7 @@ module.exports = { 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', diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 01cc309b8..ad11a95ac 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -121,8 +121,11 @@ export class MattermostMarkdownRenderer extends marked.Renderer { paragraph(text) { let outText = text; + // required so markdown does not strip '_' from @user_names + outText = TextFormatting.doFormatMentions(text); + if (!('emoticons' in this.options) || this.options.emoticon) { - outText = TextFormatting.doFormatEmoticons(text); + outText = TextFormatting.doFormatEmoticons(outText); } if (this.formattingOptions.singleline) { @@ -136,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer { 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); } } diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 4b6d87254..9f1a5a53f 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -47,8 +47,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 = autolinkUrls(output, tokens); output = autolinkHashtags(output, tokens); if (!('emoticons' in options) || options.emoticon) { @@ -78,6 +78,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; @@ -188,6 +195,7 @@ function autolinkAtMentions(text, tokens) { let output = text; output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); + return output; } diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 7a876d518..3140a5d77 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'); @@ -210,11 +211,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) { @@ -1009,3 +1014,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(); + } + } + ); + } +} |