diff options
Diffstat (limited to 'web/react')
187 files changed, 7109 insertions, 4555 deletions
diff --git a/web/react/.eslintrc b/web/react/.eslintrc index d78068882..baaf7eaa5 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -6,8 +6,9 @@ "modules": true, "classes": true, "arrowFunctions": true, - "defaultParams": true, + "defaultParams": true }, + "parser": "babel-eslint", "plugins": [ "react" ], @@ -21,10 +22,13 @@ "React": false, "ReactDOM": false, "ReactBootstrap": false, - "Chart": false + "Chart": false, + "katex": false }, "rules": { "comma-dangle": [2, "never"], + "no-arrow-condition": 2, + "no-case-declarations": 2, "no-cond-assign": [2, "except-parens"], "no-console": 2, "no-constant-condition": 2, @@ -33,14 +37,17 @@ "no-dupe-keys": 2, "no-duplicate-case": 2, "no-empty": 2, + "no-empty-pattern": 2, "no-ex-assign": 2, "no-extra-semi": 2, + "no-fallthrough": 2, "no-func-assign": 2, "no-inner-declarations": 0, "no-invalid-regexp": 2, "no-irregular-whitespace": 2, "no-unexpected-multiline": 2, "no-unreachable": 2, + "no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ], "valid-typeof": 2, "block-scoped-var": 2, @@ -148,6 +155,7 @@ // ES6 stuff "arrow-parens": [2, "always"], + "arrow-body-style": 0, "arrow-spacing": [2, { "before": true, "after": true }], "constructor-super": 2, "generator-star-spacing": [2, {"before": false, "after": true}], @@ -170,7 +178,9 @@ "react/jsx-closing-bracket-location": [2, { "location": "tag-aligned" }], "react/jsx-curly-spacing": [2, "never"], "react/jsx-indent-props": [2, 4], + "react/jsx-key": 2, "react/jsx-max-props-per-line": [2, { "maximum": 1 }], + "react/jsx-no-bind": 1, "react/jsx-no-duplicate-props": [2, { "ignoreCase": false }], "react/jsx-no-literals": 1, "react/jsx-no-undef": 2, @@ -179,13 +189,14 @@ "react/no-danger": 0, "react/no-did-mount-set-state": 2, "react/no-did-update-set-state": 2, - "react/no-multi-comp": 2, + "react/no-direct-mutation-state": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], "react/no-set-state": 0, "react/no-unknown-property": 2, + "react/prefer-es6-class": 2, "react/prop-types": 2, "react/self-closing-comp": 2, "react/sort-comp": 0, - "react/wrap-multilines": 2, - "react/no-direct-mutation-state": 2 + "react/wrap-multilines": 2 } } diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index 27959ec7e..85c28ca5c 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -2,11 +2,11 @@ // See License.txt for license information. var Modal = ReactBootstrap.Modal; -var UserStore = require('../stores/user_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var LoadingScreen = require('./loading_screen.jsx'); -var Utils = require('../utils/utils.jsx'); +import UserStore from '../stores/user_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import LoadingScreen from './loading_screen.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class AccessHistoryModal extends React.Component { constructor(props) { @@ -14,8 +14,8 @@ export default class AccessHistoryModal extends React.Component { this.onAuditChange = this.onAuditChange.bind(this); this.handleMoreInfo = this.handleMoreInfo.bind(this); - this.onHide = this.onHide.bind(this); this.onShow = this.onShow.bind(this); + this.onHide = this.onHide.bind(this); this.formatAuditInfo = this.formatAuditInfo.bind(this); this.handleRevokedSession = this.handleRevokedSession.bind(this); @@ -32,17 +32,23 @@ export default class AccessHistoryModal extends React.Component { onShow() { AsyncClient.getAudits(); - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 300); if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); } } onHide() { this.setState({moreInfo: []}); - this.props.onModalDismissed(); + this.props.onHide(); } componentDidMount() { UserStore.addAuditsChangeListener(this.onAuditChange); + + if (this.props.show) { + this.onShow(); + } } componentDidUpdate(prevProps) { if (this.props.show && !prevProps.show) { @@ -54,7 +60,7 @@ export default class AccessHistoryModal extends React.Component { } onAuditChange() { var newState = this.getStateFromStoresForAudits(); - if (!Utils.areStatesEqual(newState.audits, this.state.audits)) { + if (!Utils.areObjectsEqual(newState.audits, this.state.audits)) { this.setState(newState); } } @@ -102,7 +108,7 @@ export default class AccessHistoryModal extends React.Component { case '/channels/update_header': currentAuditDesc = 'Updated the ' + channelName + ' channel/group header'; break; - default: + default: { let userIdField = []; let userId = ''; let username = ''; @@ -126,11 +132,12 @@ export default class AccessHistoryModal extends React.Component { break; } + } } else if (currentActionURL.indexOf('/oauth') === 0) { const oauthInfo = currentAudit.extra_info.split(' '); switch (currentActionURL) { - case '/oauth/register': + case '/oauth/register': { const clientIdField = oauthInfo[0].split('='); if (clientIdField[0] === 'client_id') { @@ -138,6 +145,7 @@ export default class AccessHistoryModal extends React.Component { } break; + } case '/oauth/allow': if (oauthInfo[0] === 'attempt') { currentAuditDesc = 'Attempted to allow a new OAuth service access'; @@ -198,7 +206,7 @@ export default class AccessHistoryModal extends React.Component { } break; - case '/users/update_roles': + case '/users/update_roles': { const userRoles = userInfo[0].split('=')[1]; currentAuditDesc = 'Updated user role(s) to '; @@ -209,7 +217,8 @@ export default class AccessHistoryModal extends React.Component { } break; - case '/users/update_active': + } + case '/users/update_active': { const updateType = userInfo[0].split('=')[0]; const updateField = userInfo[0].split('=')[1]; @@ -236,6 +245,7 @@ export default class AccessHistoryModal extends React.Component { } break; + } case '/users/send_password_reset': currentAuditDesc = 'Sent an email to ' + userInfo[0].split('=')[1] + ' to reset your password'; break; @@ -406,5 +416,5 @@ export default class AccessHistoryModal extends React.Component { AccessHistoryModal.propTypes = { show: React.PropTypes.bool.isRequired, - onModalDismissed: React.PropTypes.func.isRequired + onHide: React.PropTypes.func.isRequired }; diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx index ef3077470..f5341c0bc 100644 --- a/web/react/components/activity_log_modal.jsx +++ b/web/react/components/activity_log_modal.jsx @@ -1,12 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const UserStore = require('../stores/user_store.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); +import UserStore from '../stores/user_store.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; const Modal = ReactBootstrap.Modal; -const LoadingScreen = require('./loading_screen.jsx'); -const Utils = require('../utils/utils.jsx'); +import LoadingScreen from './loading_screen.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class ActivityLogModal extends React.Component { constructor(props) { @@ -51,17 +51,23 @@ export default class ActivityLogModal extends React.Component { onShow() { AsyncClient.getSessions(); - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 300); if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); } } onHide() { this.setState({moreInfo: []}); - this.props.onModalDismissed(); + this.props.onHide(); } componentDidMount() { UserStore.addSessionsChangeListener(this.onListenerChange); + + if (this.props.show) { + this.onShow(); + } } componentDidUpdate(prevProps) { if (this.props.show && !prevProps.show) { @@ -73,7 +79,7 @@ export default class ActivityLogModal extends React.Component { } onListenerChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState.sessions, this.state.sessions)) { + if (!Utils.areObjectsEqual(newState.sessions, this.state.sessions)) { this.setState(newState); } } @@ -178,5 +184,5 @@ export default class ActivityLogModal extends React.Component { ActivityLogModal.propTypes = { show: React.PropTypes.bool.isRequired, - onModalDismissed: React.PropTypes.func.isRequired + onHide: React.PropTypes.func.isRequired }; diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 8e0ab0555..e587c4f84 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -1,25 +1,26 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AdminSidebar = require('./admin_sidebar.jsx'); -var AdminStore = require('../../stores/admin_store.jsx'); -var TeamStore = require('../../stores/team_store.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var LoadingScreen = require('../loading_screen.jsx'); -var Utils = require('../../utils/utils.jsx'); - -var EmailSettingsTab = require('./email_settings.jsx'); -var LogSettingsTab = require('./log_settings.jsx'); -var LogsTab = require('./logs.jsx'); -var FileSettingsTab = require('./image_settings.jsx'); -var PrivacySettingsTab = require('./privacy_settings.jsx'); -var RateSettingsTab = require('./rate_settings.jsx'); -var GitLabSettingsTab = require('./gitlab_settings.jsx'); -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'); +import AdminSidebar from './admin_sidebar.jsx'; +import AdminStore from '../../stores/admin_store.jsx'; +import TeamStore from '../../stores/team_store.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import LoadingScreen from '../loading_screen.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +import EmailSettingsTab from './email_settings.jsx'; +import LogSettingsTab from './log_settings.jsx'; +import LogsTab from './logs.jsx'; +import FileSettingsTab from './image_settings.jsx'; +import PrivacySettingsTab from './privacy_settings.jsx'; +import RateSettingsTab from './rate_settings.jsx'; +import GitLabSettingsTab from './gitlab_settings.jsx'; +import SqlSettingsTab from './sql_settings.jsx'; +import TeamSettingsTab from './team_settings.jsx'; +import ServiceSettingsTab from './service_settings.jsx'; +import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; +import TeamUsersTab from './team_users.jsx'; +import TeamAnalyticsTab from './team_analytics.jsx'; export default class AdminController extends React.Component { constructor(props) { @@ -148,6 +149,8 @@ export default class AdminController extends React.Component { tab = <TeamSettingsTab config={this.state.config} />; } else if (this.state.selected === 'service_settings') { tab = <ServiceSettingsTab config={this.state.config} />; + } else if (this.state.selected === 'legal_and_support_settings') { + tab = <LegalAndSupportSettingsTab config={this.state.config} />; } else if (this.state.selected === 'team_users') { if (this.state.teams) { tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; @@ -189,4 +192,12 @@ export default class AdminController extends React.Component { </div> ); } -}
\ No newline at end of file +} + +AdminController.defaultProps = { +}; + +AdminController.propTypes = { + tab: React.PropTypes.string, + teamId: React.PropTypes.string +}; diff --git a/web/react/components/admin_console/admin_navbar_dropdown.jsx b/web/react/components/admin_console/admin_navbar_dropdown.jsx index df8da94e1..783d45de6 100644 --- a/web/react/components/admin_console/admin_navbar_dropdown.jsx +++ b/web/react/components/admin_console/admin_navbar_dropdown.jsx @@ -1,11 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../../utils/utils.jsx'); -var Client = require('../../utils/client.jsx'); -var TeamStore = require('../../stores/team_store.jsx'); +import * as Utils from '../../utils/utils.jsx'; +import * as Client from '../../utils/client.jsx'; +import TeamStore from '../../stores/team_store.jsx'; -var Constants = require('../../utils/constants.jsx'); +import Constants from '../../utils/constants.jsx'; function getStateFromStores() { return {currentTeam: TeamStore.getCurrent()}; diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 0d52ae347..da445da37 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -1,9 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AdminSidebarHeader = require('./admin_sidebar_header.jsx'); -var SelectTeamModal = require('./select_team_modal.jsx'); -var Utils = require('../../utils/utils.jsx'); +import AdminSidebarHeader from './admin_sidebar_header.jsx'; +import SelectTeamModal from './select_team_modal.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +const Tooltip = ReactBootstrap.Tooltip; +const OverlayTrigger = ReactBootstrap.OverlayTrigger; export default class AdminSidebar extends React.Component { constructor(props) { @@ -80,6 +83,12 @@ export default class AdminSidebar extends React.Component { render() { var count = '*'; var teams = 'Loading'; + const removeTooltip = ( + <Tooltip id='remove-team-tooltip'>{'Remove team from sidebar menu'}</Tooltip> + ); + const addTeamTooltip = ( + <Tooltip id='add-team-tooltip'>{'Add team from sidebar menu'}</Tooltip> + ); if (this.props.teams != null) { count = '' + Object.keys(this.props.teams).length; @@ -99,17 +108,22 @@ export default class AdminSidebar extends React.Component { <a href='#' onClick={this.handleClick.bind(this, 'team_users', team.id)} - className={'nav__sub-menu-item ' + this.isSelected('team_users', team.id)} + className={'nav__sub-menu-item ' + this.isSelected('team_users', team.id) + ' ' + this.isSelected('team_analytics', team.id)} > {team.name} + <OverlayTrigger + delayShow={1000} + placement='top' + overlay={removeTooltip} + > <span className='menu-icon--right menu__close' onClick={this.removeTeam.bind(this, team.id)} style={{cursor: 'pointer'}} - title='Remove team from sidebar menu' > - {'x'} + {'×'} </span> + </OverlayTrigger> </a> </li> <li> @@ -238,6 +252,15 @@ export default class AdminSidebar extends React.Component { {'GitLab Settings'} </a> </li> + <li> + <a + href='#' + className={this.isSelected('legal_and_support_settings')} + onClick={this.handleClick.bind(this, 'legal_and_support_settings', null)} + > + {'Legal and Support Settings'} + </a> + </li> </ul> <ul className='nav nav__sub-menu'> <li> @@ -245,15 +268,20 @@ export default class AdminSidebar extends React.Component { <span className='icon fa fa-gear'></span> <span>{'TEAMS (' + count + ')'}</span> <span className='menu-icon--right'> + <OverlayTrigger + delayShow={1000} + placement='top' + overlay={addTeamTooltip} + > <a href='#' onClick={this.showTeamSelect} > <i className='fa fa-plus' - title='Add team to sidebar menu' ></i> </a> + </OverlayTrigger> </span> </h4> </li> diff --git a/web/react/components/admin_console/admin_sidebar_header.jsx b/web/react/components/admin_console/admin_sidebar_header.jsx index fd6d92c4a..bfd479939 100644 --- a/web/react/components/admin_console/admin_sidebar_header.jsx +++ b/web/react/components/admin_console/admin_sidebar_header.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AdminNavbarDropdown = require('./admin_navbar_dropdown.jsx'); -var UserStore = require('../../stores/user_store.jsx'); -var Utils = require('../../utils/utils.jsx'); +import AdminNavbarDropdown from './admin_navbar_dropdown.jsx'; +import UserStore from '../../stores/user_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; export default class SidebarHeader extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index 40e00ff04..238ace3da 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var crypto = require('crypto'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import crypto from 'crypto'; export default class EmailSettings extends React.Component { constructor(props) { @@ -18,6 +18,7 @@ export default class EmailSettings extends React.Component { this.state = { sendEmailNotifications: this.props.config.EmailSettings.SendEmailNotifications, + sendPushNotifications: this.props.config.EmailSettings.SendPushNotifications, saveNeeded: false, serverError: null, emailSuccess: null, @@ -36,6 +37,14 @@ export default class EmailSettings extends React.Component { s.sendEmailNotifications = false; } + if (action === 'sendPushNotifications_true') { + s.sendPushNotifications = true; + } + + if (action === 'sendPushNotifications_false') { + s.sendPushNotifications = false; + } + this.setState(s); } @@ -43,11 +52,12 @@ export default class EmailSettings extends React.Component { var config = this.props.config; config.EmailSettings.EnableSignUpWithEmail = ReactDOM.findDOMNode(this.refs.allowSignUpWithEmail).checked; config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked; + config.EmailSettings.SendPushlNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked; config.EmailSettings.RequireEmailVerification = ReactDOM.findDOMNode(this.refs.requireEmailVerification).checked; - config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked; config.EmailSettings.FeedbackName = ReactDOM.findDOMNode(this.refs.feedbackName).value.trim(); config.EmailSettings.FeedbackEmail = ReactDOM.findDOMNode(this.refs.feedbackEmail).value.trim(); config.EmailSettings.SMTPServer = ReactDOM.findDOMNode(this.refs.SMTPServer).value.trim(); + config.EmailSettings.PushNotificationServer = ReactDOM.findDOMNode(this.refs.PushNotificationServer).value.trim(); config.EmailSettings.SMTPPort = ReactDOM.findDOMNode(this.refs.SMTPPort).value.trim(); config.EmailSettings.SMTPUsername = ReactDOM.findDOMNode(this.refs.SMTPUsername).value.trim(); config.EmailSettings.SMTPPassword = ReactDOM.findDOMNode(this.refs.SMTPPassword).value.trim(); @@ -296,7 +306,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='feedbackName' ref='feedbackName' - placeholder='Ex: "Mattermost Notification", "System", "No-Reply"' + placeholder='E.g.: "Mattermost Notification", "System", "No-Reply"' defaultValue={this.props.config.EmailSettings.FeedbackName} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -318,7 +328,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='feedbackEmail' ref='feedbackEmail' - placeholder='Ex: "mattermost@yourcompany.com", "admin@yourcompany.com"' + placeholder='E.g.: "mattermost@yourcompany.com", "admin@yourcompany.com"' defaultValue={this.props.config.EmailSettings.FeedbackEmail} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -340,7 +350,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPUsername' ref='SMTPUsername' - placeholder='Ex: "admin@yourcompany.com", "AKIADTOVBGERKLCBV"' + placeholder='E.g.: "admin@yourcompany.com", "AKIADTOVBGERKLCBV"' defaultValue={this.props.config.EmailSettings.SMTPUsername} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -362,7 +372,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPPassword' ref='SMTPPassword' - placeholder='Ex: "yourpassword", "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"' + placeholder='E.g.: "yourpassword", "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"' defaultValue={this.props.config.EmailSettings.SMTPPassword} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -384,7 +394,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPServer' ref='SMTPServer' - placeholder='Ex: "smtp.yourcompany.com", "email-smtp.us-east-1.amazonaws.com"' + placeholder='E.g.: "smtp.yourcompany.com", "email-smtp.us-east-1.amazonaws.com"' defaultValue={this.props.config.EmailSettings.SMTPServer} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -406,7 +416,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPPort' ref='SMTPPort' - placeholder='Ex: "25", "465"' + placeholder='E.g.: "25", "465"' defaultValue={this.props.config.EmailSettings.SMTPPort} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -476,7 +486,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='InviteSalt' ref='InviteSalt' - placeholder='Ex "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' + placeholder='E.g.: "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' defaultValue={this.props.config.EmailSettings.InviteSalt} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -507,7 +517,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='PasswordResetSalt' ref='PasswordResetSalt' - placeholder='Ex "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' + placeholder='E.g.: "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' defaultValue={this.props.config.EmailSettings.PasswordResetSalt} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -526,6 +536,61 @@ export default class EmailSettings extends React.Component { </div> <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='sendPushNotifications' + > + {'Send Push Notifications: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='sendPushNotifications' + value='true' + ref='sendPushNotifications' + defaultChecked={this.props.config.EmailSettings.SendPushNotifications} + onChange={this.handleChange.bind(this, 'sendPushNotifications_true')} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='sendPushNotifications' + value='false' + defaultChecked={!this.props.config.EmailSettings.SendPushNotifications} + onChange={this.handleChange.bind(this, 'sendPushNotifications_false')} + /> + {'false'} + </label> + <p className='help-text'>{'Typically set to true in production. When true, Mattermost attempts to send iOS and Android push notifications through the push notification server.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='PushNotificationServer' + > + {'Push Notification Server:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='PushNotificationServer' + ref='PushNotificationServer' + placeholder='E.g.: "https://push.mattermost.com"' + defaultValue={this.props.config.EmailSettings.PushNotificationServer} + onChange={this.handleChange} + disabled={!this.state.sendPushNotifications} + /> + <p className='help-text'>{'Location of the push notification server.'}</p> + </div> + </div> + + <div className='form-group'> <div className='col-sm-12'> {serverError} <button diff --git a/web/react/components/admin_console/gitlab_settings.jsx b/web/react/components/admin_console/gitlab_settings.jsx index f8fb6d115..8c689a2d8 100644 --- a/web/react/components/admin_console/gitlab_settings.jsx +++ b/web/react/components/admin_console/gitlab_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class GitLabSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/image_settings.jsx b/web/react/components/admin_console/image_settings.jsx index 8b577e012..e1ffad7d3 100644 --- a/web/react/components/admin_console/image_settings.jsx +++ b/web/react/components/admin_console/image_settings.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var crypto = require('crypto'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import crypto from 'crypto'; export default class FileSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/legal_and_support_settings.jsx b/web/react/components/admin_console/legal_and_support_settings.jsx new file mode 100644 index 000000000..b00e4b6bd --- /dev/null +++ b/web/react/components/admin_console/legal_and_support_settings.jsx @@ -0,0 +1,222 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +export default class LegalAndSupportSettings extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + saveNeeded: false, + serverError: null + }; + } + + handleChange() { + var s = {saveNeeded: true, serverError: this.state.serverError}; + this.setState(s); + } + + handleSubmit(e) { + e.preventDefault(); + $('#save-button').button('loading'); + + var config = this.props.config; + + config.SupportSettings.TermsOfServiceLink = ReactDOM.findDOMNode(this.refs.TermsOfServiceLink).value.trim(); + config.SupportSettings.PrivacyPolicyLink = ReactDOM.findDOMNode(this.refs.PrivacyPolicyLink).value.trim(); + config.SupportSettings.AboutLink = ReactDOM.findDOMNode(this.refs.AboutLink).value.trim(); + config.SupportSettings.HelpLink = ReactDOM.findDOMNode(this.refs.HelpLink).value.trim(); + config.SupportSettings.ReportAProblemLink = ReactDOM.findDOMNode(this.refs.ReportAProblemLink).value.trim(); + config.SupportSettings.SupportEmail = ReactDOM.findDOMNode(this.refs.SupportEmail).value.trim(); + + Client.saveConfig( + config, + () => { + AsyncClient.getConfig(); + this.setState({ + serverError: null, + saveNeeded: false + }); + $('#save-button').button('reset'); + }, + (err) => { + this.setState({ + serverError: err.message, + saveNeeded: true + }); + $('#save-button').button('reset'); + } + ); + } + + render() { + var serverError = ''; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var saveClass = 'btn'; + if (this.state.saveNeeded) { + saveClass = 'btn btn-primary'; + } + + return ( + <div className='wrapper--fixed'> + + <h3>{'Legal and Support Settings'}</h3> + <form + className='form-horizontal' + role='form' + > + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='TermsOfServiceLink' + > + {'Terms of Service link:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='TermsOfServiceLink' + ref='TermsOfServiceLink' + defaultValue={this.props.config.SupportSettings.TermsOfServiceLink} + onChange={this.handleChange} + /> + <p className='help-text'>{'Link to Terms of Service available to users on desktop and on mobile. Leaving this blank will hide the option to display a notice.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='PrivacyPolicyLink' + > + {'Privacy Policy link:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='PrivacyPolicyLink' + ref='PrivacyPolicyLink' + defaultValue={this.props.config.SupportSettings.PrivacyPolicyLink} + onChange={this.handleChange} + /> + <p className='help-text'>{'Link to Privacy Policy available to users on desktop and on mobile. Leaving this blank will hide the option to display a notice.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='AboutLink' + > + {'About link:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='AboutLink' + ref='AboutLink' + defaultValue={this.props.config.SupportSettings.AboutLink} + onChange={this.handleChange} + /> + <p className='help-text'>{'Link to About page for more information on your Mattermost deployment, for example its purpose and audience within your organization. Defaults to Mattermost information page.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='HelpLink' + > + {'Help link:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='HelpLink' + ref='HelpLink' + defaultValue={this.props.config.SupportSettings.HelpLink} + onChange={this.handleChange} + /> + <p className='help-text'>{'Link to help documentation from team site main menu. Typically not changed unless your organization chooses to create custom documentation.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='ReportAProblemLink' + > + {'Report a Problem link:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='ReportAProblemLink' + ref='ReportAProblemLink' + defaultValue={this.props.config.SupportSettings.ReportAProblemLink} + onChange={this.handleChange} + /> + <p className='help-text'>{'Link to help documentation from team site main menu. By default this points to the peer-to-peer troubleshooting forum where users can search for, find and request help with technical issues.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='SupportEmail' + > + {'Support email:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='SupportEmail' + ref='SupportEmail' + defaultValue={this.props.config.SupportSettings.SupportEmail} + onChange={this.handleChange} + /> + <p className='help-text'>{'Email shown during tutorial for end users to ask support questions.'}</p> + </div> + </div> + + <div className='form-group'> + <div className='col-sm-12'> + {serverError} + <button + disabled={!this.state.saveNeeded} + type='submit' + className={saveClass} + onClick={this.handleSubmit} + id='save-button' + data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> Saving Config...'} + > + {'Save'} + </button> + </div> + </div> + + </form> + </div> + ); + } +} + +LegalAndSupportSettings.propTypes = { + config: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/log_settings.jsx b/web/react/components/admin_console/log_settings.jsx index 7e9eda89b..a91cc57ab 100644 --- a/web/react/components/admin_console/log_settings.jsx +++ b/web/react/components/admin_console/log_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class LogSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/logs.jsx b/web/react/components/admin_console/logs.jsx index 3449c78d9..01135f1b8 100644 --- a/web/react/components/admin_console/logs.jsx +++ b/web/react/components/admin_console/logs.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AdminStore = require('../../stores/admin_store.jsx'); -var LoadingScreen = require('../loading_screen.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import AdminStore from '../../stores/admin_store.jsx'; +import LoadingScreen from '../loading_screen.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class Logs extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/privacy_settings.jsx b/web/react/components/admin_console/privacy_settings.jsx index f2d22f36e..61393f1c6 100644 --- a/web/react/components/admin_console/privacy_settings.jsx +++ b/web/react/components/admin_console/privacy_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class PrivacySettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/rate_settings.jsx b/web/react/components/admin_console/rate_settings.jsx index 4d71777c4..ca9fcb074 100644 --- a/web/react/components/admin_console/rate_settings.jsx +++ b/web/react/components/admin_console/rate_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class RateSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/reset_password_modal.jsx b/web/react/components/admin_console/reset_password_modal.jsx index 35d3cdd17..5ff7c3413 100644 --- a/web/react/components/admin_console/reset_password_modal.jsx +++ b/web/react/components/admin_console/reset_password_modal.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); +import * as Client from '../../utils/client.jsx'; var Modal = ReactBootstrap.Modal; export default class ResetPasswordModal extends React.Component { diff --git a/web/react/components/admin_console/select_team_modal.jsx b/web/react/components/admin_console/select_team_modal.jsx index 22189821b..858b6bbfe 100644 --- a/web/react/components/admin_console/select_team_modal.jsx +++ b/web/react/components/admin_console/select_team_modal.jsx @@ -57,7 +57,7 @@ export default class SelectTeamModal extends React.Component { <select ref='team' size='10' - style={{width: '100%'}} + className='form-control' > {options} </select> diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx index 53c89a942..1f5faf1d4 100644 --- a/web/react/components/admin_console/service_settings.jsx +++ b/web/react/components/admin_console/service_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class ServiceSettings extends React.Component { constructor(props) { @@ -36,7 +36,7 @@ export default class ServiceSettings extends React.Component { config.ServiceSettings.SegmentDeveloperKey = ReactDOM.findDOMNode(this.refs.SegmentDeveloperKey).value.trim(); config.ServiceSettings.GoogleDeveloperKey = ReactDOM.findDOMNode(this.refs.GoogleDeveloperKey).value.trim(); config.ServiceSettings.EnableIncomingWebhooks = ReactDOM.findDOMNode(this.refs.EnableIncomingWebhooks).checked; - config.ServiceSettings.EnableOutgoingWebhooks = React.findDOMNode(this.refs.EnableOutgoingWebhooks).checked; + config.ServiceSettings.EnableOutgoingWebhooks = ReactDOM.findDOMNode(this.refs.EnableOutgoingWebhooks).checked; config.ServiceSettings.EnablePostUsernameOverride = ReactDOM.findDOMNode(this.refs.EnablePostUsernameOverride).checked; config.ServiceSettings.EnablePostIconOverride = ReactDOM.findDOMNode(this.refs.EnablePostIconOverride).checked; config.ServiceSettings.EnableTesting = ReactDOM.findDOMNode(this.refs.EnableTesting).checked; diff --git a/web/react/components/admin_console/sql_settings.jsx b/web/react/components/admin_console/sql_settings.jsx index b43108bf7..2a55f7324 100644 --- a/web/react/components/admin_console/sql_settings.jsx +++ b/web/react/components/admin_console/sql_settings.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var crypto = require('crypto'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import crypto from 'crypto'; export default class SqlSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx index 0c9d1f61b..e28699d3c 100644 --- a/web/react/components/admin_console/team_analytics.jsx +++ b/web/react/components/admin_console/team_analytics.jsx @@ -1,9 +1,9 @@ // 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'); +import * as Client from '../../utils/client.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import LineChart from './line_chart.jsx'; export default class TeamAnalytics extends React.Component { constructor(props) { @@ -221,7 +221,7 @@ export default class TeamAnalytics extends React.Component { var openChannelCount = ( <div className='col-sm-3'> <div className='total-count'> - <div className='title'>{'Public Groups'}<i className='fa fa-unlock-alt'/></div> + <div className='title'>{'Public Channels'}<i className='fa fa-globe'/></div> <div className='content'>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div> </div> </div> diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx index 6587184ea..7991b9a01 100644 --- a/web/react/components/admin_console/team_settings.jsx +++ b/web/react/components/admin_console/team_settings.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; export default class TeamSettings extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/team_users.jsx b/web/react/components/admin_console/team_users.jsx index 7161139e6..2d9657956 100644 --- a/web/react/components/admin_console/team_users.jsx +++ b/web/react/components/admin_console/team_users.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../../utils/client.jsx'); -var LoadingScreen = require('../loading_screen.jsx'); -var UserItem = require('./user_item.jsx'); -var ResetPasswordModal = require('./reset_password_modal.jsx'); +import * as Client from '../../utils/client.jsx'; +import LoadingScreen from '../loading_screen.jsx'; +import UserItem from './user_item.jsx'; +import ResetPasswordModal from './reset_password_modal.jsx'; export default class UserList extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/user_item.jsx b/web/react/components/admin_console/user_item.jsx index 2badaf0e5..ef0b61460 100644 --- a/web/react/components/admin_console/user_item.jsx +++ b/web/react/components/admin_console/user_item.jsx @@ -1,8 +1,8 @@ // 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'); +import * as Client from '../../utils/client.jsx'; +import * as Utils from '../../utils/utils.jsx'; export default class UserItem extends React.Component { constructor(props) { @@ -227,7 +227,6 @@ export default class UserItem extends React.Component { href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' data-toggle='dropdown' aria-expanded='true' > @@ -237,7 +236,6 @@ export default class UserItem extends React.Component { <ul className='dropdown-menu member-menu' role='menu' - aria-labelledby='channel_header_dropdown' > {makeAdmin} {makeMember} diff --git a/web/react/components/authorize.jsx b/web/react/components/authorize.jsx index 74709bcab..32e39fbff 100644 --- a/web/react/components/authorize.jsx +++ b/web/react/components/authorize.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); +import * as Client from '../utils/client.jsx'; export default class Authorize extends React.Component { constructor(props) { diff --git a/web/react/components/center_panel.jsx b/web/react/components/center_panel.jsx index 3ee40bb86..a1043431d 100644 --- a/web/react/components/center_panel.jsx +++ b/web/react/components/center_panel.jsx @@ -1,17 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const TutorialIntroScreens = require('./tutorial/tutorial_intro_screens.jsx'); -const CreatePost = require('./create_post.jsx'); -const PostsViewContainer = require('./posts_view_container.jsx'); -const ChannelHeader = require('./channel_header.jsx'); -const Navbar = require('./navbar.jsx'); -const FileUploadOverlay = require('./file_upload_overlay.jsx'); +import TutorialIntroScreens from './tutorial/tutorial_intro_screens.jsx'; +import CreatePost from './create_post.jsx'; +import PostsViewContainer from './posts_view_container.jsx'; +import PostFocusView from './post_focus_view.jsx'; +import ChannelHeader from './channel_header.jsx'; +import Navbar from './navbar.jsx'; +import FileUploadOverlay from './file_upload_overlay.jsx'; -const PreferenceStore = require('../stores/preference_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); +import PreferenceStore from '../stores/preference_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; -const Constants = require('../utils/constants.jsx'); +import * as Utils from '../utils/utils.jsx'; + +import Constants from '../utils/constants.jsx'; const TutorialSteps = Constants.TutorialSteps; const Preferences = Constants.Preferences; @@ -20,26 +24,68 @@ export default class CenterPanel extends React.Component { super(props); this.onPreferenceChange = this.onPreferenceChange.bind(this); + this.onChannelChange = this.onChannelChange.bind(this); const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '999'}); - this.state = {showTutorialScreens: parseInt(tutorialPref.value, 10) === TutorialSteps.INTRO_SCREENS}; + this.state = { + showTutorialScreens: parseInt(tutorialPref.value, 10) === TutorialSteps.INTRO_SCREENS, + showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS + }; } componentDidMount() { PreferenceStore.addChangeListener(this.onPreferenceChange); + ChannelStore.addChangeListener(this.onChannelChange); } componentWillUnmount() { PreferenceStore.removeChangeListener(this.onPreferenceChange); + ChannelStore.removeChangeListener(this.onChannelChange); } onPreferenceChange() { const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '999'}); this.setState({showTutorialScreens: parseInt(tutorialPref.value, 10) <= TutorialSteps.INTRO_SCREENS}); } + onChannelChange() { + this.setState({showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS}); + } render() { + const channel = ChannelStore.getCurrent(); + var handleClick = null; let postsContainer; + let createPost; if (this.state.showTutorialScreens) { postsContainer = <TutorialIntroScreens />; + createPost = null; + } else if (this.state.showPostFocus) { + postsContainer = <PostFocusView />; + + handleClick = function clickHandler(e) { + e.preventDefault(); + Utils.switchChannel(channel); + }; + + createPost = ( + <div + id='archive-link-home' + > + <a + href='' + onClick={handleClick} + > + {'You are viewing the Archives. Click here to jump to recent messages. '} + {<i className='fa fa-arrow-down'></i>} + </a> + </div> + ); } else { postsContainer = <PostsViewContainer />; + createPost = ( + <div + className='post-create__container' + id='post-create' + > + <CreatePost /> + </div> + ); } return ( @@ -62,12 +108,7 @@ export default class CenterPanel extends React.Component { <ChannelHeader /> </div> {postsContainer} - <div - className='post-create__container' - id='post-create' - > - <CreatePost /> - </div> + {createPost} </div> </div> </div> diff --git a/web/react/components/change_url_modal.jsx b/web/react/components/change_url_modal.jsx index 714e93ff8..bbe93f58d 100644 --- a/web/react/components/change_url_modal.jsx +++ b/web/react/components/change_url_modal.jsx @@ -2,7 +2,7 @@ // See License.txt for license information. var Modal = ReactBootstrap.Modal; -var Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; export default class ChangeUrlModal extends React.Component { constructor(props) { diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 895dc5fe4..59ceb038e 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -1,28 +1,34 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const NavbarSearchBox = require('./search_bar.jsx'); -const MessageWrapper = require('./message_wrapper.jsx'); -const PopoverListMembers = require('./popover_list_members.jsx'); -const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); -const ChannelInviteModal = require('./channel_invite_modal.jsx'); -const ChannelMembersModal = require('./channel_members_modal.jsx'); +import NavbarSearchBox from './search_bar.jsx'; +import MessageWrapper from './message_wrapper.jsx'; +import PopoverListMembers from './popover_list_members.jsx'; +import EditChannelHeaderModal from './edit_channel_header_modal.jsx'; +import EditChannelPurposeModal from './edit_channel_purpose_modal.jsx'; +import ChannelInfoModal from './channel_info_modal.jsx'; +import ChannelInviteModal from './channel_invite_modal.jsx'; +import ChannelMembersModal from './channel_members_modal.jsx'; +import ChannelNotificationsModal from './channel_notifications_modal.jsx'; +import DeleteChannelModal from './delete_channel_modal.jsx'; +import ToggleModalButton from './toggle_modal_button.jsx'; -const ChannelStore = require('../stores/channel_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const SearchStore = require('../stores/search_store.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import SearchStore from '../stores/search_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const Utils = require('../utils/utils.jsx'); -const TextFormatting = require('../utils/text_formatting.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const Client = require('../utils/client.jsx'); -const Constants = require('../utils/constants.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as Utils from '../utils/utils.jsx'; +import * as TextFormatting from '../utils/text_formatting.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const Popover = ReactBootstrap.Popover; const OverlayTrigger = ReactBootstrap.OverlayTrigger; +const Tooltip = ReactBootstrap.Tooltip; export default class ChannelHeader extends React.Component { constructor(props) { @@ -34,16 +40,18 @@ export default class ChannelHeader extends React.Component { const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; - state.showInviteModal = false; state.showMembersModal = false; this.state = state; } getStateFromStores() { + const extraInfo = ChannelStore.getCurrentExtraInfo(); + return { channel: ChannelStore.getCurrent(), memberChannel: ChannelStore.getCurrentMember(), memberTeam: UserStore.getCurrentUser(), - users: ChannelStore.getCurrentExtraInfo().members, + users: extraInfo.members, + userCount: extraInfo.member_count, searchVisible: SearchStore.getSearchResults() !== null }; } @@ -63,7 +71,7 @@ export default class ChannelHeader extends React.Component { } onListenerChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } $('.channel-header__info .description').popover({placement: 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}}); @@ -92,9 +100,11 @@ export default class ChannelHeader extends React.Component { let terms = ''; if (user.notify_props && user.notify_props.mention_keys) { const termKeys = UserStore.getCurrentMentionKeys(); + if (user.notify_props.all === 'true' && termKeys.indexOf('@all') !== -1) { termKeys.splice(termKeys.indexOf('@all'), 1); } + if (user.notify_props.channel === 'true' && termKeys.indexOf('@channel') !== -1) { termKeys.splice(termKeys.indexOf('@channel'), 1); } @@ -114,6 +124,7 @@ export default class ChannelHeader extends React.Component { } const channel = this.state.channel; + const recentMentionsTooltip = <Tooltip id='recentMentionsTooltip'>{'Recent Mentions'}</Tooltip>; const popoverContent = ( <Popover id='hader-popover' @@ -158,17 +169,13 @@ export default class ChannelHeader extends React.Component { key='edit_header_direct' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} > {'Set Channel Header...'} - </a> + </ToggleModalButton> </li> ); } else { @@ -177,15 +184,13 @@ export default class ChannelHeader extends React.Component { key='view_info' role='presentation' > - <a + <ToggleModalButton role='menuitem' - data-toggle='modal' - data-target='#channel_info' - data-channelid={channel.id} - href='#' + dialogType={ChannelInfoModal} + dialogProps={{channel}} > {'View Info'} - </a> + </ToggleModalButton> </li> ); @@ -195,13 +200,13 @@ export default class ChannelHeader extends React.Component { key='add_members' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - onClick={() => this.setState({showInviteModal: true})} + dialogType={ChannelInviteModal} + dialogProps={{channel}} > {'Add Members'} - </a> + </ToggleModalButton> </li> ); @@ -228,17 +233,13 @@ export default class ChannelHeader extends React.Component { key='set_channel_header' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} > - {'Set '}{channelTerm}{' Header...'} - </a> + {`Set ${channelTerm} Header...`} + </ToggleModalButton> </li> ); dropdownContents.push( @@ -260,58 +261,55 @@ export default class ChannelHeader extends React.Component { key='notification_preferences' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#channel_notifications' - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={ChannelNotificationsModal} + dialogProps={{channel}} > {'Notification Preferences'} - </a> + </ToggleModalButton> </li> ); - if (!ChannelStore.isDefault(channel)) { - if (isAdmin) { - dropdownContents.push( - <li - key='rename_channel' - role='presentation' + if (isAdmin) { + dropdownContents.push( + <li + key='rename_channel' + role='presentation' + > + <a + role='menuitem' + href='#' + data-toggle='modal' + data-target='#rename_channel' + data-display={channel.display_name} + data-name={channel.name} + data-channelid={channel.id} > - <a - role='menuitem' - href='#' - data-toggle='modal' - data-target='#rename_channel' - data-display={channel.display_name} - data-name={channel.name} - data-channelid={channel.id} - > - {'Rename '}{channelTerm}{'...'} - </a> - </li> - ); + {'Rename '}{channelTerm}{'...'} + </a> + </li> + ); + + if (!ChannelStore.isDefault(channel)) { dropdownContents.push( <li key='delete_channel' role='presentation' > - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#delete_channel' - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={DeleteChannelModal} + dialogProps={{channel}} > {'Delete '}{channelTerm}{'...'} - </a> + </ToggleModalButton> </li> ); } + } + if (!ChannelStore.isDefault(channel)) { dropdownContents.push( <li key='leave_channel' @@ -373,37 +371,26 @@ export default class ChannelHeader extends React.Component { <th> <PopoverListMembers members={this.state.users} + memberCount={this.state.userCount} channelId={channel.id} /> </th> <th className='search-bar__container'><NavbarSearchBox /></th> <th> <div className='dropdown channel-header__links'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - id='channel_header_right_dropdown' - data-toggle='dropdown' - aria-expanded='true' - > - <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> - </a> - <ul - className='dropdown-menu dropdown-menu-right' - role='menu' - aria-labelledby='channel_header_right_dropdown' + <OverlayTrigger + delayShow={400} + placement='bottom' + overlay={recentMentionsTooltip} > - <li role='presentation'> - <a - role='menuitem' - href='#' - onClick={this.searchMentions} - > - {'Recent Mentions'} - </a> - </li> - </ul> + <a + href='#' + type='button' + onClick={this.searchMentions} + > + {'@'} + </a> + </OverlayTrigger> </div> </th> </tr> @@ -414,13 +401,10 @@ export default class ChannelHeader extends React.Component { onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} channel={channel} /> - <ChannelInviteModal - show={this.state.showInviteModal} - onModalDismissed={() => this.setState({showInviteModal: false})} - /> <ChannelMembersModal show={this.state.showMembersModal} onModalDismissed={() => this.setState({showMembersModal: false})} + channel={channel} /> </div> ); diff --git a/web/react/components/channel_info_modal.jsx b/web/react/components/channel_info_modal.jsx index bccd8d0b9..18e125de3 100644 --- a/web/react/components/channel_info_modal.jsx +++ b/web/react/components/channel_info_modal.jsx @@ -1,88 +1,57 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); - -export default class CommandList extends React.Component { - constructor(props) { - super(props); - - this.state = { - channel_id: ChannelStore.getCurrentId() - }; - } - - componentDidMount() { - var self = this; - if (this.refs.modal) { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', function show(e) { - var button = e.relatedTarget; - self.setState({channel_id: $(button).attr('data-channelid')}); - }); - } - } +const Modal = ReactBootstrap.Modal; +export default class ChannelInfoModal extends React.Component { render() { - var channel = ChannelStore.get(this.state.channel_id); - + let channel = this.props.channel; if (!channel) { - channel = {}; - channel.display_name = 'No Channel Found'; - channel.name = 'No Channel Found'; - channel.id = 'No Channel Found'; + channel = { + display_name: 'No Channel Found', + name: 'No Channel Found', + id: 'No Channel Found' + }; } return ( - <div - className='modal fade' - ref='modal' - id='channel_info' - tabIndex='-1' - role='dialog' - aria-hidden='true' + <Modal + show={this.props.show} + onHide={this.props.onHide} > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 - className='modal-title' - id='myModalLabel' - > - <span className='name'>{channel.display_name}</span> - </h4> - </div> - <div className='modal-body'> - <div className='row form-group'> - <div className='col-sm-3 info__label'>Channel Name: </div> + <Modal.Header closeButtton={true}> + {channel.display_name} + </Modal.Header> + <Modal.Body ref='modalBody'> + <div className='row form-group'> + <div className='col-sm-3 info__label'>{'Channel Name:'}</div> <div className='col-sm-9'>{channel.display_name}</div> - </div> - <div className='row form-group'> - <div className='col-sm-3 info__label'>Channel Handle:</div> + </div> + <div className='row form-group'> + <div className='col-sm-3 info__label'>{'Channel Handle:'}</div> <div className='col-sm-9'>{channel.name}</div> - </div> - <div className='row'> - <div className='col-sm-3 info__label'>Channel ID:</div> - <div className='col-sm-9'>{channel.id}</div> - </div> </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - >Close</button> + <div className='row'> + <div className='col-sm-3 info__label'>{'Channel ID:'}</div> + <div className='col-sm-9'>{channel.id}</div> </div> - </div> - </div> - </div> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.props.onHide} + > + {'Close'} + </button> + </Modal.Footer> + </Modal> ); } } + +ChannelInfoModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index 7c1032321..7dac39942 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -1,15 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const MemberList = require('./member_list.jsx'); -const LoadingScreen = require('./loading_screen.jsx'); +import MemberList from './member_list.jsx'; +import LoadingScreen from './loading_screen.jsx'; -const UserStore = require('../stores/user_store.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); +import UserStore from '../stores/user_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; const Modal = ReactBootstrap.Modal; @@ -22,6 +22,17 @@ export default class ChannelInviteModal extends React.Component { this.state = this.getStateFromStores(); } + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(this.props, nextProps)) { + return true; + } + + if (!Utils.areObjectsEqual(this.state, nextState)) { + return true; + } + + return false; + } getStateFromStores() { function getId(user) { return user.id; @@ -42,21 +53,17 @@ export default class ChannelInviteModal extends React.Component { return a.username.localeCompare(b.username); }); - var channelName = ''; - if (ChannelStore.getCurrent()) { - channelName = ChannelStore.getCurrent().display_name; - } - return { nonmembers, - memberIds, - channelName, loading }; } onShow() { if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); } } componentDidUpdate(prevProps) { @@ -78,34 +85,20 @@ export default class ChannelInviteModal extends React.Component { } onListenerChange() { var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } } handleInvite(userId) { - // Make sure the user isn't already a member of the channel - if (this.state.memberIds.indexOf(userId) > -1) { - return; - } - var data = {}; data.user_id = userId; - Client.addChannelMember(ChannelStore.getCurrentId(), data, + Client.addChannelMember( + this.props.channel.id, + data, () => { - var nonmembers = this.state.nonmembers; - var memberIds = this.state.memberIds; - - for (var i = 0; i < nonmembers.length; i++) { - if (userId === nonmembers[i].id) { - nonmembers[i].invited = true; - memberIds.push(userId); - break; - } - } - - this.setState({inviteError: null, memberIds, nonmembers}); - AsyncClient.getChannelExtraInfo(true); + this.setState({inviteError: null}); + AsyncClient.getChannelExtraInfo(); }, (err) => { this.setState({inviteError: err.message}); @@ -113,11 +106,6 @@ export default class ChannelInviteModal extends React.Component { ); } render() { - var maxHeight = 1000; - if (Utils.windowHeight() <= 1200) { - maxHeight = Utils.windowHeight() - 300; - } - var inviteError = null; if (this.state.inviteError) { inviteError = (<label className='has-error control-label'>{this.state.inviteError}</label>); @@ -146,14 +134,13 @@ export default class ChannelInviteModal extends React.Component { <Modal dialogClassName='more-modal' show={this.props.show} - onHide={this.props.onModalDismissed} + onHide={this.props.onHide} > <Modal.Header closeButton={true}> - <Modal.Title>{'Add New Members to '}<span className='name'>{this.state.channelName}</span></Modal.Title> + <Modal.Title>{'Add New Members to '}<span className='name'>{this.props.channel.display_name}</span></Modal.Title> </Modal.Header> <Modal.Body ref='modalBody' - style={{maxHeight}} > {inviteError} {content} @@ -162,7 +149,7 @@ export default class ChannelInviteModal extends React.Component { <button type='button' className='btn btn-default' - onClick={this.props.onModalDismissed} + onClick={this.props.onHide} > {'Close'} </button> @@ -174,5 +161,6 @@ export default class ChannelInviteModal extends React.Component { ChannelInviteModal.propTypes = { show: React.PropTypes.bool.isRequired, - onModalDismissed: React.PropTypes.func.isRequired + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired }; diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index 4fc115a92..0d1d9efd7 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -5,14 +5,15 @@ to the server on page load. This is to prevent other React controls from spamming AsyncClient with requests. */ -var AsyncClient = require('../utils/async_client.jsx'); -var SocketStore = require('../stores/socket_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var PostStore = require('../stores/post_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); +import * as AsyncClient from '../utils/async_client.jsx'; +import SocketStore from '../stores/socket_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; -var Utils = require('../utils/utils.jsx'); -var Constants = require('../utils/constants.jsx'); +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; export default class ChannelLoader extends React.Component { constructor(props) { @@ -27,8 +28,8 @@ export default class ChannelLoader extends React.Component { componentDidMount() { /* Initial aysnc loads */ AsyncClient.getPosts(ChannelStore.getCurrentId()); - AsyncClient.getChannels(true, true); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannels(); + AsyncClient.getChannelExtraInfo(); AsyncClient.findTeams(); AsyncClient.getMyTeam(); setTimeout(() => AsyncClient.getStatuses(), 3000); // temporary until statuses are reworked a bit @@ -69,6 +70,10 @@ export default class ChannelLoader extends React.Component { Utils.applyTheme(Constants.THEMES.default); } + // if preferences have already been stored in local storage do not wait until preference store change is fired and handled in channel.jsx + const selectedFont = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', {value: Constants.DEFAULT_FONT}).value; + Utils.applyFont(selectedFont); + $('body').on('mouseenter mouseleave', '.post', function mouseOver(ev) { if (ev.type === 'mouseenter') { $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--after'); diff --git a/web/react/components/channel_members_modal.jsx b/web/react/components/channel_members_modal.jsx index 2fa7ae8ff..d1b9df988 100644 --- a/web/react/components/channel_members_modal.jsx +++ b/web/react/components/channel_members_modal.jsx @@ -1,15 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const MemberList = require('./member_list.jsx'); -const ChannelInviteModal = require('./channel_invite_modal.jsx'); +import MemberList from './member_list.jsx'; +import ChannelInviteModal from './channel_invite_modal.jsx'; -const UserStore = require('../stores/user_store.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); +import UserStore from '../stores/user_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; -const AsyncClient = require('../utils/async_client.jsx'); -const Client = require('../utils/client.jsx'); -const Utils = require('../utils/utils.jsx'); +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; const Modal = ReactBootstrap.Modal; @@ -25,6 +25,17 @@ export default class ChannelMembersModal extends React.Component { state.showInviteModal = false; this.state = state; } + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(this.props, nextProps)) { + return true; + } + + if (!Utils.areObjectsEqual(this.state, nextState)) { + return true; + } + + return false; + } getStateFromStores() { const users = UserStore.getActiveOnlyProfiles(); const memberList = ChannelStore.getCurrentExtraInfo().members; @@ -58,22 +69,16 @@ export default class ChannelMembersModal extends React.Component { memberList.sort(compareByUsername); nonmemberList.sort(compareByUsername); - const channel = ChannelStore.getCurrent(); - let channelName = ''; - if (channel) { - channelName = channel.display_name; - } - return { nonmemberList, - memberList, - channelName + memberList }; } onShow() { if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); } + this.onChange(); } componentDidUpdate(prevProps) { if (this.props.show && !prevProps.show) { @@ -91,7 +96,7 @@ export default class ChannelMembersModal extends React.Component { } onChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } } @@ -130,7 +135,7 @@ export default class ChannelMembersModal extends React.Component { } this.setState({memberList, nonmemberList}); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); }, (err) => { this.setState({inviteError: err.message}); @@ -157,7 +162,7 @@ export default class ChannelMembersModal extends React.Component { onHide={this.props.onModalDismissed} > <Modal.Header closeButton={true}> - <Modal.Title><span className='name'>{this.state.channelName}</span>{' Members'}</Modal.Title> + <Modal.Title><span className='name'>{this.props.channel.display_name}</span>{' Members'}</Modal.Title> <a className='btn btn-md btn-primary' href='#' @@ -193,7 +198,8 @@ export default class ChannelMembersModal extends React.Component { </Modal> <ChannelInviteModal show={this.state.showInviteModal} - onModalDismissed={() => this.setState({showInviteModal: false})} + onHide={() => this.setState({showInviteModal: false})} + channel={this.props.channel} /> </div> ); @@ -206,5 +212,6 @@ ChannelMembersModal.defaultProps = { ChannelMembersModal.propTypes = { show: React.PropTypes.bool.isRequired, - onModalDismissed: React.PropTypes.func.isRequired + onModalDismissed: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired }; diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications_modal.jsx index 43700bf36..e70d3a634 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications_modal.jsx @@ -1,15 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); +var Modal = ReactBootstrap.Modal; +import SettingItemMin from './setting_item_min.jsx'; +import SettingItemMax from './setting_item_max.jsx'; -var Utils = require('../utils/utils.jsx'); -var Client = require('../utils/client.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); +import * as Client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; -export default class ChannelNotifications extends React.Component { +export default class ChannelNotificationsModal extends React.Component { constructor(props) { super(props); @@ -23,55 +23,39 @@ export default class ChannelNotifications extends React.Component { this.handleSubmitMarkUnreadLevel = this.handleSubmitMarkUnreadLevel.bind(this); this.handleUpdateMarkUnreadLevel = this.handleUpdateMarkUnreadLevel.bind(this); this.createMarkUnreadLevelSection = this.createMarkUnreadLevelSection.bind(this); - this.onShow = this.onShow.bind(this); + const member = ChannelStore.getMember(props.channel.id); this.state = { - notifyLevel: '', - markUnreadLevel: '', - title: '', - channelId: '', + notifyLevel: member.notify_props.desktop, + markUnreadLevel: member.notify_props.mark_unread, + channelId: ChannelStore.getCurrentId(), activeSection: '' }; } - onShow(e) { - var button = e.relatedTarget; - var channelId = button.getAttribute('data-channelid'); - - const member = ChannelStore.getMember(channelId); - var notifyLevel = member.notify_props.desktop; - var markUnreadLevel = member.notify_props.mark_unread; - - this.setState({ - notifyLevel, - markUnreadLevel, - title: button.getAttribute('data-title'), - channelId - }); - } - componentDidMount() { - ChannelStore.addChangeListener(this.onListenerChange); - - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - } - componentWillUnmount() { - ChannelStore.removeChangeListener(this.onListenerChange); + componentWillReceiveProps(nextProps) { + if (!this.props.show && nextProps.show) { + this.onListenerChange(); + ChannelStore.addChangeListener(this.onListenerChange); + } else { + ChannelStore.removeChangeListener(this.onListenerChange); + } } onListenerChange() { - if (!this.state.channelId) { + const curChannelId = ChannelStore.getCurrentId(); + + if (!curChannelId) { return; } - const member = ChannelStore.getMember(this.state.channelId); - var notifyLevel = member.notify_props.desktop; - var markUnreadLevel = member.notify_props.mark_unread; + const newState = {channelId: curChannelId}; + const member = ChannelStore.getMember(curChannelId); - var newState = this.state; - newState.notifyLevel = notifyLevel; - newState.markUnreadLevel = markUnreadLevel; - - if (!Utils.areStatesEqual(this.state, newState)) { - this.setState(newState); + if (member.notify_props.desktop !== this.state.notifyLevel || member.notify_props.mark_unread !== this.state.mark_unread) { + newState.notifyLevel = member.notify_props.desktop; + newState.markUnreadLevel = member.notify_props.mark_unread; } + + this.setState(newState); } updateSection(section) { this.setState({activeSection: section}); @@ -104,7 +88,6 @@ export default class ChannelNotifications extends React.Component { } handleUpdateNotifyLevel(notifyLevel) { this.setState({notifyLevel}); - ReactDOM.findDOMNode(this.refs.modal).focus(); } createNotifyLevelSection(serverError) { var handleUpdateSection; @@ -262,7 +245,6 @@ export default class ChannelNotifications extends React.Component { handleUpdateMarkUnreadLevel(markUnreadLevel) { this.setState({markUnreadLevel}); - ReactDOM.findDOMNode(this.refs.modal).focus(); } createMarkUnreadLevelSection(serverError) { @@ -347,48 +329,39 @@ export default class ChannelNotifications extends React.Component { } return ( - <div - className='modal fade' - id='channel_notifications' - ref='modal' - tabIndex='-1' - role='dialog' - aria-hidden='true' + <Modal + show={this.props.show} + dialogClassName='settings-modal' + onHide={this.props.onHide} > - <div className='modal-dialog settings-modal'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' + <Modal.Header closeButton={true}> + <Modal.Title>{'Notification Preferences for '}<span className='name'>{this.props.channel.display_name}</span></Modal.Title> + </Modal.Header> + <Modal.Body> + <div className='settings-table'> + <div className='settings-content'> + <div + ref='wrapper' + className='user-settings' > - <span aria-hidden='true'>×</span> - <span className='sr-only'>{'Close'}</span> - </button> - <h4 className='modal-title'>Notification Preferences for <span className='name'>{this.state.title}</span></h4> - </div> - <div className='modal-body'> - <div className='settings-table'> - <div className='settings-content'> - <div - ref='wrapper' - className='user-settings' - > - <br/> - <div className='divider-dark first'/> - {this.createNotifyLevelSection(serverError)} - <div className='divider-light'/> - {this.createMarkUnreadLevelSection(serverError)} - <div className='divider-dark'/> - </div> + <br/> + <div className='divider-dark first'/> + {this.createNotifyLevelSection(serverError)} + <div className='divider-light'/> + {this.createMarkUnreadLevelSection(serverError)} + <div className='divider-dark'/> </div> - </div> - {serverError} </div> </div> - </div> - </div> + {serverError} + </Modal.Body> + </Modal> ); } } + +ChannelNotificationsModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/channel_view.jsx b/web/react/components/channel_view.jsx index 3f53a94c2..7cbb638a0 100644 --- a/web/react/components/channel_view.jsx +++ b/web/react/components/channel_view.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const CenterPanel = require('../components/center_panel.jsx'); -const Sidebar = require('../components/sidebar.jsx'); -const SidebarRight = require('../components/sidebar_right.jsx'); -const SidebarRightMenu = require('../components/sidebar_right_menu.jsx'); +import CenterPanel from '../components/center_panel.jsx'; +import Sidebar from '../components/sidebar.jsx'; +import SidebarRight from '../components/sidebar_right.jsx'; +import SidebarRightMenu from '../components/sidebar_right_menu.jsx'; export default class ChannelView extends React.Component { constructor(props) { diff --git a/web/react/components/command_list.jsx b/web/react/components/command_list.jsx deleted file mode 100644 index a6d9d5d70..000000000 --- a/web/react/components/command_list.jsx +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -var client = require('../utils/client.jsx'); - -export default class CommandList extends React.Component { - constructor(props) { - super(props); - - this.handleClick = this.handleClick.bind(this); - this.addFirstCommand = this.addFirstCommand.bind(this); - this.isEmpty = this.isEmpty.bind(this); - this.getSuggestedCommands = this.getSuggestedCommands.bind(this); - - this.state = { - suggestions: [ ], - cmd: '' - }; - } - - handleClick(i) { - this.props.addCommand(this.state.suggestions[i].suggestion); - this.setState({suggestions: [ ], cmd: ''}); - } - - addFirstCommand() { - if (this.state.suggestions.length === 0) { - return; - } - this.handleClick(0); - } - - isEmpty() { - return this.state.suggestions.length === 0; - } - - getSuggestedCommands(cmd) { - if (!cmd || cmd.charAt(0) !== '/') { - this.setState({suggestions: [ ], cmd: ''}); - return; - } - - client.executeCommand( - this.props.channelId, - cmd, - true, - function success(data) { - if (data.suggestions.length === 1 && data.suggestions[0].suggestion === cmd) { - data.suggestions = []; - } - this.setState({suggestions: data.suggestions, cmd: cmd}); - }.bind(this), - function fail() { - } - ); - } - - render() { - if (this.state.suggestions.length === 0) { - return (<div/>); - } - - var suggestions = []; - - for (var i = 0; i < this.state.suggestions.length; i++) { - if (this.state.suggestions[i].suggestion !== this.state.cmd) { - suggestions.push( - <div - key={i} - className='command-name' - onClick={this.handleClick.bind(this, i)} - > - <div className='command__title'><strong>{this.state.suggestions[i].suggestion}</strong></div> - <div className='command__desc'>{this.state.suggestions[i].description}</div> - </div> - ); - } - } - - return ( - <div - ref='mentionlist' - className='command-box' - style={{height: (this.state.suggestions.length * 56) + 2}} - > - {suggestions} - </div> - ); - } -} - -CommandList.defaultProps = { - channelId: null -}; - -CommandList.propTypes = { - addCommand: React.PropTypes.func, - channelId: React.PropTypes.string -}; diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 058594165..b0f33eda1 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -1,21 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const PostStore = require('../stores/post_store.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); -const Textbox = require('./textbox.jsx'); -const MsgTyping = require('./msg_typing.jsx'); -const FileUpload = require('./file_upload.jsx'); -const FilePreview = require('./file_preview.jsx'); -const Utils = require('../utils/utils.jsx'); - -const Constants = require('../utils/constants.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import SocketStore from '../stores/socket_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import Textbox from './textbox.jsx'; +import MsgTyping from './msg_typing.jsx'; +import FileUpload from './file_upload.jsx'; +import FilePreview from './file_preview.jsx'; +import * as Utils from '../utils/utils.jsx'; + +import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const KeyCodes = Constants.KeyCodes; @@ -34,7 +34,6 @@ export default class CreateComment extends React.Component { this.handleUploadError = this.handleUploadError.bind(this); this.handleTextDrop = this.handleTextDrop.bind(this); this.removePreview = this.removePreview.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleResize = this.handleResize.bind(this); this.onPreferenceChange = this.onPreferenceChange.bind(this); @@ -194,7 +193,8 @@ export default class CreateComment extends React.Component { title: 'Comment', message: lastPost.message, postId: lastPost.id, - channelId: lastPost.channel_id + channelId: lastPost.channel_id, + comments: PostStore.getCommentCount(lastPost) }); } } @@ -334,6 +334,7 @@ export default class CreateComment extends React.Component { messageText={this.state.messageText} createMessage='Add a comment...' initialText='' + supportsCommands={false} id='reply_textbox' ref='textbox' /> @@ -361,11 +362,11 @@ export default class CreateComment extends React.Component { onClick={this.handleSubmit} /> {uploadsInProgressText} + {preview} {postError} {serverError} </div> </div> - {preview} </form> ); } diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 5a69c9bfb..89e984e27 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -1,24 +1,26 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const MsgTyping = require('./msg_typing.jsx'); -const Textbox = require('./textbox.jsx'); -const FileUpload = require('./file_upload.jsx'); -const FilePreview = require('./file_preview.jsx'); -const TutorialTip = require('./tutorial/tutorial_tip.jsx'); - -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const Utils = require('../utils/utils.jsx'); - -const ChannelStore = require('../stores/channel_store.jsx'); -const PostStore = require('../stores/post_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); -const SocketStore = require('../stores/socket_store.jsx'); - -const Constants = require('../utils/constants.jsx'); +import MsgTyping from './msg_typing.jsx'; +import Textbox from './textbox.jsx'; +import FileUpload from './file_upload.jsx'; +import FilePreview from './file_preview.jsx'; +import TutorialTip from './tutorial/tutorial_tip.jsx'; + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Utils from '../utils/utils.jsx'; + +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import SocketStore from '../stores/socket_store.jsx'; + +import Constants from '../utils/constants.jsx'; + const Preferences = Constants.Preferences; const TutorialSteps = Constants.TutorialSteps; const ActionTypes = Constants.ActionTypes; @@ -176,9 +178,7 @@ export default class CreatePost extends React.Component { const channel = ChannelStore.get(this.state.channelId); - PostStore.storePendingPost(post); - PostStore.storeDraft(channel.id, null); - PostStore.jumpPostsViewToBottom(); + EventHelpers.emitUserPostedEvent(post); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); Client.createPost(post, channel, @@ -190,10 +190,7 @@ export default class CreatePost extends React.Component { member.last_viewed_at = Date.now(); ChannelStore.setChannelMember(member); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST, - post: data - }); + EventHelpers.emitPostRecievedEvent(data); }, (err) => { const state = {}; @@ -372,7 +369,8 @@ export default class CreatePost extends React.Component { title: type, message: lastPost.message, postId: lastPost.id, - channelId: lastPost.channel_id + channelId: lastPost.channel_id, + comments: PostStore.getCommentCount(lastPost) }); } } @@ -382,8 +380,8 @@ export default class CreatePost extends React.Component { screens.push( <div> <h4>{'Sending Messages'}</h4> - <p>{'Type here to write a message.'}</p> - <p>{'Click the attachment button to upload an image or a file.'}</p> + <p>{'Type here to write a message and press '}<strong>{'Enter'}</strong>{' to post it.'}</p> + <p>{'Click the '}<strong>{'Attachment'}</strong>{' button to upload an image or a file.'}</p> </div> ); @@ -472,13 +470,13 @@ export default class CreatePost extends React.Component { {tutorialTip} </div> <div className={postFooterClassName}> - {postError} - {serverError} - {preview} <MsgTyping channelId={this.state.channelId} parentId='' /> + {preview} + {postError} + {serverError} </div> </div> </form> diff --git a/web/react/components/delete_channel_modal.jsx b/web/react/components/delete_channel_modal.jsx index b7d633b38..1255067fd 100644 --- a/web/react/components/delete_channel_modal.jsx +++ b/web/react/components/delete_channel_modal.jsx @@ -1,102 +1,74 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +const Modal = ReactBootstrap.Modal; +import TeamStore from '../stores/team_store.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class DeleteChannelModal extends React.Component { constructor(props) { super(props); this.handleDelete = this.handleDelete.bind(this); - this.onShow = this.onShow.bind(this); - - this.state = { - title: '', - channelId: '' - }; } + handleDelete() { - if (this.state.channelId.length !== 26) { + if (this.props.channel.id.length !== 26) { return; } - Client.deleteChannel(this.state.channelId, - function handleDeleteSuccess() { + Client.deleteChannel( + this.props.channel.id, + () => { AsyncClient.getChannels(true); window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/town-square'; }, - function handleDeleteError(err) { + (err) => { AsyncClient.dispatchError(err, 'handleDelete'); } ); } - onShow(e) { - var button = $(e.relatedTarget); - this.setState({ - title: button.attr('data-title'), - channelId: button.attr('data-channelid') - }); - } - componentDidMount() { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - } + render() { - const channel = ChannelStore.getCurrent(); - let channelType = 'channel'; - if (channel && channel.type === 'P') { - channelType = 'private group'; - } + const channelTerm = Utils.getChannelTerm(this.props.channel.type).toLowerCase(); return ( - <div - className='modal fade' - ref='modal' - id='delete_channel' - role='dialog' - tabIndex='-1' - aria-hidden='true' + <Modal + show={this.props.show} + onHide={this.props.onHide} > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 className='modal-title'>Confirm DELETE Channel</h4> - </div> - <div className='modal-body'> - <p> - Are you sure you wish to delete the {this.state.title} {channelType}? - </p> - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - Cancel - </button> - <button - type='button' - className='btn btn-danger' - data-dismiss='modal' - onClick={this.handleDelete} - > - Delete - </button> - </div> - </div> - </div> - </div> + <Modal.Header closeButton={true}> + <h4 className='modal-title'>{'Confirm DELETE Channel'}</h4> + </Modal.Header> + <Modal.Body> + {`Are you sure you wish to delete the ${this.props.channel.display_name} ${channelTerm}?`} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.props.onHide} + > + {'Cancel'} + </button> + <button + type='button' + className='btn btn-danger' + data-dismiss='modal' + onClick={this.handleDelete} + > + {'Delete'} + </button> + </Modal.Footer> + </Modal> ); } } + +DeleteChannelModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index 3a3dabce5..827654e1b 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -1,13 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); -var PostStore = require('../stores/post_store.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var Utils = require('../utils/utils.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); +import * as Client from '../utils/client.jsx'; +import PostStore from '../stores/post_store.jsx'; +import ModalStore from '../stores/modal_store.jsx'; +var Modal = ReactBootstrap.Modal; +import * as Utils from '../utils/utils.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; export default class DeletePostModal extends React.Component { @@ -15,18 +16,40 @@ export default class DeletePostModal extends React.Component { super(props); this.handleDelete = this.handleDelete.bind(this); + this.handleToggle = this.handleToggle.bind(this); + this.handleHide = this.handleHide.bind(this); this.onListenerChange = this.onListenerChange.bind(this); - this.onShow = this.onShow.bind(this); - this.state = {title: '', postId: '', channelId: '', selectedList: PostStore.getSelectedPost(), comments: 0}; + this.selectedList = null; + + this.state = { + show: true, + post: null, + commentCount: 0, + error: '' + }; + } + + componentDidMount() { + ModalStore.addModalListener(ActionTypes.TOGGLE_DELETE_POST_MODAL, this.handleToggle); + PostStore.addSelectedPostChangeListener(this.onListenerChange); } + + componentWillUnmount() { + PostStore.removeSelectedPostChangeListener(this.onListenerChange); + ModalStore.removeModalListener(ActionTypes.TOGGLE_DELETE_POST_MODAL, this.handleToggle); + } + handleDelete() { - Client.deletePost(this.state.channelId, this.state.postId, - function deleteSuccess() { - var selectedList = this.state.selectedList; + Client.deletePost( + this.state.post.channel_id, + this.state.post.id, + () => { + var selectedList = this.selectedList; + if (selectedList && selectedList.order && selectedList.order.length > 0) { var selectedPost = selectedList.posts[selectedList.order[0]]; - if ((selectedPost.id === this.state.postId && this.state.title === 'Post') || selectedPost.root_id === this.state.postId) { + if ((selectedPost.id === this.state.post.id && !this.state.root_id) || selectedPost.root_id === this.state.post.id) { AppDispatcher.handleServerAction({ type: ActionTypes.RECIEVED_SEARCH, results: null @@ -36,7 +59,7 @@ export default class DeletePostModal extends React.Component { type: ActionTypes.RECIEVED_POST_SELECTED, results: null }); - } else if (selectedPost.id === this.state.postId && this.state.title === 'Comment') { + } else if (selectedPost.id === this.state.post.id && this.state.root_id) { if (selectedPost.root_id && selectedPost.root_id.length > 0 && selectedList.posts[selectedPost.root_id]) { selectedList.order = [selectedPost.root_id]; delete selectedList.posts[selectedPost.id]; @@ -53,98 +76,88 @@ export default class DeletePostModal extends React.Component { } } } - PostStore.removePost(this.state.postId, this.state.channelId); - AsyncClient.getPosts(this.state.channelId); - }.bind(this), - function deleteFailed(err) { + + PostStore.removePost(this.state.post.id, this.state.post.channel_id); + AsyncClient.getPosts(this.state.post.channel_id); + }, + (err) => { AsyncClient.dispatchError(err, 'deletePost'); } ); + + this.handleHide(); } - onShow(e) { - var newState = {}; - if (BrowserStore.getItem('edit_state_transfer')) { - newState = BrowserStore.getItem('edit_state_transfer'); - BrowserStore.removeItem('edit_state_transfer'); - } else { - var button = e.relatedTarget; - newState = {title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), postId: $(button).attr('data-postid'), comments: $(button).attr('data-comments')}; - } - this.setState(newState); - } - componentDidMount() { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - PostStore.addSelectedPostChangeListener(this.onListenerChange); + + handleToggle(value, args) { + this.setState({ + show: value, + post: args.post, + commentCount: args.commentCount, + error: '' + }); } - componentWillUnmount() { - PostStore.removeSelectedPostChangeListener(this.onListenerChange); + + handleHide() { + this.setState({show: false}); } + onListenerChange() { var newList = PostStore.getSelectedPost(); - if (!Utils.areStatesEqual(this.state.selectedList, newList)) { - this.setState({selectedList: newList}); + if (!Utils.areObjectsEqual(this.selectedList, newList)) { + this.selectedList = newList; } } + render() { + if (!this.state.post) { + return null; + } + var error = null; if (this.state.error) { error = <div className='form-group has-error'><label className='control-label'>{this.state.error}</label></div>; } var commentWarning = ''; - if (this.state.comments > 0) { - commentWarning = 'This post has ' + this.state.comments + ' comment(s) on it.'; + if (this.state.commentCount > 0) { + commentWarning = 'This post has ' + this.state.commentCount + ' comment(s) on it.'; } + const postTerm = Utils.getPostTerm(this.state.post); + return ( - <div - className='modal fade' - id='delete_post' - ref='modal' - role='dialog' - tabIndex='-1' - aria-hidden='true' + <Modal + show={this.state.show} + onHide={this.handleHide} > - <div className='modal-dialog modal-push-down'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 className='modal-title'>Confirm {this.state.title} Delete</h4> - </div> - <div className='modal-body'> - Are you sure you want to delete the {this.state.title.toLowerCase()}? - <br/> - <br/> + <Modal.Header closeButton={true}> + <Modal.Title>{`Confirm ${postTerm} Delete`}</Modal.Title> + </Modal.Header> + <Modal.Body> + {`Are you sure you want to delete this ${postTerm.toLowerCase()}?`} + <br /> + <br /> {commentWarning} - </div> - {error} - <div className='modal-footer'> + {error} + </Modal.Body> + <Modal.Footer> <button type='button' className='btn btn-default' - data-dismiss='modal' + onClick={this.handleHide} > - Cancel + {'Cancel'} </button> <button type='button' className='btn btn-danger' - data-dismiss='modal' onClick={this.handleDelete} + autoFocus='autofocus' > - Delete + {'Delete'} </button> - </div> - </div> - </div> - </div> + </Modal.Footer> + </Modal> ); } } diff --git a/web/react/components/docs.jsx b/web/react/components/docs.jsx new file mode 100644 index 000000000..188ca340b --- /dev/null +++ b/web/react/components/docs.jsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as TextFormatting from '../utils/text_formatting.jsx'; +import UserStore from '../stores/user_store.jsx'; + +export default class Docs extends React.Component { + constructor(props) { + super(props); + UserStore.setCurrentUser(global.window.mm_user || {}); + + this.state = {text: ''}; + const errorState = {text: '## 404'}; + + if (props.site) { + $.get('/static/help/' + props.site + '.md').then((response) => { + this.setState({text: response}); + }, () => { + this.setState(errorState); + }); + } else { + this.setState(errorState); + } + } + + render() { + return ( + <div + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.text)}} + > + </div> + ); + } +} + +Docs.defaultProps = { + site: '' +}; +Docs.propTypes = { + site: React.PropTypes.string +}; diff --git a/web/react/components/edit_channel_header_modal.jsx b/web/react/components/edit_channel_header_modal.jsx new file mode 100644 index 000000000..e4817f6e4 --- /dev/null +++ b/web/react/components/edit_channel_header_modal.jsx @@ -0,0 +1,142 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as Client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; +import * as Utils from '../utils/utils.jsx'; + +const Modal = ReactBootstrap.Modal; + +export default class EditChannelHeaderModal extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + + this.onShow = this.onShow.bind(this); + this.onHide = this.onHide.bind(this); + + this.state = { + header: props.channel.header, + serverError: '' + }; + } + + componentDidMount() { + if (this.props.show) { + this.onShow(); + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.channel.header !== nextProps.channel.header) { + this.setState({ + header: nextProps.channel.header + }); + } + } + + componentDidUpdate(prevProps) { + if (this.props.show && !prevProps.show) { + this.onShow(); + } + } + + handleChange(e) { + this.setState({ + header: e.target.value + }); + } + + handleSubmit() { + Client.updateChannelHeader( + this.props.channel.id, + this.state.header, + (channel) => { + this.setState({serverError: ''}); + this.onHide(); + + AppDispatcher.handleServerAction({ + type: Constants.ActionTypes.RECIEVED_CHANNEL, + channel + }); + }, + (err) => { + if (err.message === 'Invalid channel_header parameter') { + this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); + } else { + this.setState({serverError: err.message}); + } + } + ); + } + + onShow() { + const textarea = ReactDOM.findDOMNode(this.refs.textarea); + Utils.placeCaretAtEnd(textarea); + } + + onHide() { + this.setState({ + serverError: '', + header: this.props.channel.header + }); + + this.props.onHide(); + } + + render() { + var serverError = null; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><br/><label className='control-label'>{this.state.serverError}</label></div>; + } + + return ( + <Modal + show={this.props.show} + onHide={this.onHide} + > + <Modal.Header closeButton={true}> + <Modal.Title>{'Edit Header for ' + this.props.channel.display_name}</Modal.Title> + </Modal.Header> + <Modal.Body> + <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> + <textarea + ref='textarea' + className='form-control no-resize' + rows='6' + id='edit_header' + maxLength='1024' + value={this.state.header} + onChange={this.handleChange} + /> + {serverError} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.onHide} + > + {'Cancel'} + </button> + <button + type='button' + className='btn btn-primary' + onClick={this.handleSubmit} + > + {'Save'} + </button> + </Modal.Footer> + </Modal> + ); + } +} + +EditChannelHeaderModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + channel: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx deleted file mode 100644 index 2557a55ca..000000000 --- a/web/react/components/edit_channel_modal.jsx +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); - -export default class EditChannelModal extends React.Component { - constructor(props) { - super(props); - - this.handleEdit = this.handleEdit.bind(this); - this.handleUserInput = this.handleUserInput.bind(this); - this.handleClose = this.handleClose.bind(this); - this.onShow = this.onShow.bind(this); - this.handleShown = this.handleShown.bind(this); - - this.state = { - header: '', - title: '', - channelId: '', - serverError: '' - }; - } - handleEdit() { - var data = {}; - data.channel_id = this.state.channelId; - - if (data.channel_id.length !== 26) { - return; - } - - data.channel_header = this.state.header.trim(); - - Client.updateChannelHeader(data, - () => { - this.setState({serverError: ''}); - AsyncClient.getChannel(this.state.channelId); - $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - }, - (err) => { - if (err.message === 'Invalid channel_header parameter') { - this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); - } else { - this.setState({serverError: err.message}); - } - } - ); - } - handleUserInput(e) { - this.setState({header: e.target.value}); - } - handleClose() { - this.setState({header: '', serverError: ''}); - } - onShow(e) { - const button = e.relatedTarget; - this.setState({header: $(button).attr('data-header'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); - } - handleShown() { - $('#edit_channel #edit_header').focus(); - } - componentDidMount() { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - $(ReactDOM.findDOMNode(this.refs.modal)).on('hidden.bs.modal', this.handleClose); - $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', this.handleShown); - } - componentWillUnmount() { - $(ReactDOM.findDOMNode(this.refs.modal)).off('hidden.bs.modal', this.handleClose); - } - render() { - var serverError = null; - if (this.state.serverError) { - serverError = <div className='form-group has-error'><br/><label className='control-label'>{this.state.serverError}</label></div>; - } - - var editTitle = ( - <h4 - className='modal-title' - ref='title' - > - {'Edit Header'} - </h4> - ); - if (this.state.title) { - editTitle = ( - <h4 - className='modal-title' - ref='title' - > - {'Edit Header for '}<span className='name'>{this.state.title}</span> - </h4> - ); - } - - return ( - <div - className='modal fade' - ref='modal' - id='edit_channel' - role='dialog' - tabIndex='-1' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>{'×'}</span> - </button> - {editTitle} - </div> - <div className='modal-body'> - <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> - <textarea - className='form-control no-resize' - rows='6' - id='edit_header' - maxLength='1024' - value={this.state.header} - onChange={this.handleUserInput} - /> - {serverError} - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - {'Cancel'} - </button> - <button - type='button' - className='btn btn-primary' - onClick={this.handleEdit} - > - {'Save'} - </button> - </div> - </div> - </div> - </div> - ); - } -} diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx index 65e8183de..af23342ae 100644 --- a/web/react/components/edit_channel_purpose_modal.jsx +++ b/web/react/components/edit_channel_purpose_modal.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AsyncClient = require('../utils/async_client.jsx'); -const Client = require('../utils/client.jsx'); -const Utils = require('../utils/utils.jsx'); +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; const Modal = ReactBootstrap.Modal; @@ -90,7 +90,7 @@ export default class EditChannelPurposeModal extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - <p>{`Describe how this ${Utils.getChannelTerm(this.props.channel.channelType)} should be used.`}</p> + <p>{`Describe how this ${Utils.getChannelTerm(this.props.channel.channelType)} should be used. This text appears in the channel list in the "More..." menu and helps others decide whether to join.`}</p> <textarea ref='purpose' className='form-control no-resize' diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index ef32baa7d..be57fe7c3 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -1,14 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var Textbox = require('./textbox.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var PostStore = require('../stores/post_store.jsx'); -var PreferenceStore = require('../stores/preference_store.jsx'); - -var Constants = require('../utils/constants.jsx'); +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import Textbox from './textbox.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; + +import Constants from '../utils/constants.jsx'; var KeyCodes = Constants.KeyCodes; export default class EditPostModal extends React.Component { @@ -34,7 +35,7 @@ export default class EditPostModal extends React.Component { delete tempState.editText; BrowserStore.setItem('edit_state_transfer', tempState); $('#edit_post').modal('hide'); - $('#delete_post').modal('show'); + EventHelpers.showDeletePostModal(PostStore.getPost(this.state.channel_id, this.state.post_id), this.state.comments); return; } @@ -159,6 +160,7 @@ export default class EditPostModal extends React.Component { onKeyDown={this.handleKeyDown} messageText={this.state.editText} createMessage='Edit the post...' + supportsCommands={false} id='edit_textbox' ref='editbox' /> diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx index f098384aa..921e8afe1 100644 --- a/web/react/components/error_bar.jsx +++ b/web/react/components/error_bar.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ErrorStore = require('../stores/error_store.jsx'); +import ErrorStore from '../stores/error_store.jsx'; export default class ErrorBar extends React.Component { constructor() { diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index e707e32f5..2474b3d8a 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var Client = require('../utils/client.jsx'); -var Constants = require('../utils/constants.jsx'); +import * as utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; export default class FileAttachment extends React.Component { constructor(props) { @@ -67,7 +67,7 @@ export default class FileAttachment extends React.Component { this.canSetState = false; } shouldComponentUpdate(nextProps, nextState) { - if (!utils.areStatesEqual(nextProps, this.props)) { + if (!utils.areObjectsEqual(nextProps, this.props)) { return true; } diff --git a/web/react/components/file_attachment_list.jsx b/web/react/components/file_attachment_list.jsx index ae08e5635..da1b2ba3d 100644 --- a/web/react/components/file_attachment_list.jsx +++ b/web/react/components/file_attachment_list.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ViewImageModal = require('./view_image.jsx'); -var FileAttachment = require('./file_attachment.jsx'); -var Constants = require('../utils/constants.jsx'); +import ViewImageModal from './view_image.jsx'; +import FileAttachment from './file_attachment.jsx'; +import Constants from '../utils/constants.jsx'; export default class FileAttachmentList extends React.Component { constructor(props) { diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index df5deb8bc..d625a811e 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -1,16 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; export default class FilePreview extends React.Component { constructor(props) { super(props); this.handleRemove = this.handleRemove.bind(this); + } - this.state = {}; + componentDidUpdate() { + if (this.props.uploadsInProgress.length > 0) { + ReactDOM.findDOMNode(this.refs[this.props.uploadsInProgress[0]]).scrollIntoView(); + } } + handleRemove(e) { var previewDiv = e.target.parentNode.parentNode; @@ -20,9 +25,10 @@ export default class FilePreview extends React.Component { this.props.onRemove(previewDiv.getAttribute('data-client-id')); } } + render() { var previews = []; - this.props.files.forEach(function setupPreview(fullFilename) { + this.props.files.forEach((fullFilename) => { var filename = fullFilename; var originalFilename = filename; var filenameSplit = filename.split('.'); @@ -72,11 +78,12 @@ export default class FilePreview extends React.Component { </div> ); } - }.bind(this)); + }); - this.props.uploadsInProgress.forEach(function addUploadsInProgress(clientId) { + this.props.uploadsInProgress.forEach((clientId) => { previews.push( <div + ref={clientId} key={clientId} className='preview-div' data-client-id={clientId} @@ -93,7 +100,7 @@ export default class FilePreview extends React.Component { </a> </div> ); - }.bind(this)); + }); return ( <div className='preview-container'> @@ -104,8 +111,8 @@ export default class FilePreview extends React.Component { } FilePreview.defaultProps = { - files: null, - uploadsInProgress: null + files: [], + uploadsInProgress: [] }; FilePreview.propTypes = { onRemove: React.PropTypes.func.isRequired, diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx index 8854a54df..9316ca9a5 100644 --- a/web/react/components/file_upload.jsx +++ b/web/react/components/file_upload.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var client = require('../utils/client.jsx'); -var Constants = require('../utils/constants.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var utils = require('../utils/utils.jsx'); +import * as client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import * as utils from '../utils/utils.jsx'; export default class FileUpload extends React.Component { constructor(props) { diff --git a/web/react/components/find_team.jsx b/web/react/components/find_team.jsx index bd3c11973..94ca48dbf 100644 --- a/web/react/components/find_team.jsx +++ b/web/react/components/find_team.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); +import * as utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; export default class FindTeam extends React.Component { constructor(props) { diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx index 8839bc3c7..fd20834f4 100644 --- a/web/react/components/get_link_modal.jsx +++ b/web/react/components/get_link_modal.jsx @@ -1,32 +1,28 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); +const Modal = ReactBootstrap.Modal; export default class GetLinkModal extends React.Component { constructor(props) { super(props); - this.handleClick = this.handleClick.bind(this); - this.onShow = this.onShow.bind(this); this.onHide = this.onHide.bind(this); - this.state = {copiedLink: false}; - } - onShow(e) { - var button = e.relatedTarget; - this.setState({title: $(button).attr('data-title'), value: $(button).attr('data-value')}); + this.copyLink = this.copyLink.bind(this); + + this.state = { + copiedLink: false + }; } + onHide() { this.setState({copiedLink: false}); + + this.props.onHide(); } - componentDidMount() { - if (this.refs.modal) { - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); - $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', this.onHide); - } - } - handleClick() { + + copyLink() { var copyTextarea = $(ReactDOM.findDOMNode(this.refs.textarea)); copyTextarea.select(); @@ -41,8 +37,18 @@ export default class GetLinkModal extends React.Component { this.setState({copiedLink: false}); } } + render() { - var currentUser = UserStore.getCurrentUser(); + let helpText = null; + if (this.props.helpText) { + helpText = ( + <p> + {this.props.helpText} + <br /> + <br /> + </p> + ); + } let copyLink = null; if (document.queryCommandSupported('copy')) { @@ -51,75 +57,59 @@ export default class GetLinkModal extends React.Component { data-copy-btn='true' type='button' className='btn btn-primary pull-left' - onClick={this.handleClick} - data-clipboard-text={this.state.value} + onClick={this.copyLink} > - Copy Link + {'Copy Link'} </button> ); } var copyLinkConfirm = null; if (this.state.copiedLink) { - copyLinkConfirm = <p className='alert alert-success copy-link-confirm'><i className='fa fa-check'></i> Link copied to clipboard.</p>; + copyLinkConfirm = <p className='alert alert-success copy-link-confirm'><i className='fa fa-check'></i>{' Link copied to clipboard.'}</p>; } - if (currentUser != null) { - return ( - <div - className='modal fade' - ref='modal' - id='get_link' - tabIndex='-1' - role='dialog' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 - className='modal-title' - id='myModalLabel' - > - {this.state.title} Link - </h4> - </div> - <div className='modal-body'> - <p> - Send teammates the link below for them to sign-up to this team site. - <br /><br /> - </p> - <textarea - className='form-control no-resize min-height' - readOnly='true' - ref='textarea' - value={this.state.value} - /> - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - > - Close - </button> - {copyLink} - {copyLinkConfirm} - </div> - </div> - </div> - </div> - ); - } - return <div/>; + return ( + <Modal + show={this.props.show} + onHide={this.onHide} + > + <Modal.Header closeButton={true}> + <h4 className='modal-title'>{this.props.title}</h4> + </Modal.Header> + <Modal.Body> + {helpText} + <textarea + className='form-control no-resize min-height' + readOnly='true' + ref='textarea' + value={this.props.link} + /> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.onHide} + > + {'Close'} + </button> + {copyLink} + {copyLinkConfirm} + </Modal.Footer> + </Modal> + ); } } + +GetLinkModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired, + title: React.PropTypes.string.isRequired, + helpText: React.PropTypes.string, + link: React.PropTypes.string.isRequired +}; + +GetLinkModal.defaultProps = { + helpText: null +}; diff --git a/web/react/components/get_team_invite_link_modal.jsx b/web/react/components/get_team_invite_link_modal.jsx new file mode 100644 index 000000000..a926c4451 --- /dev/null +++ b/web/react/components/get_team_invite_link_modal.jsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../utils/constants.jsx'; +import GetLinkModal from './get_link_modal.jsx'; +import ModalStore from '../stores/modal_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; + +export default class GetTeamInviteLinkModal extends React.Component { + constructor(props) { + super(props); + + this.handleToggle = this.handleToggle.bind(this); + + this.state = { + show: false + }; + } + + componentDidMount() { + ModalStore.addModalListener(Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, this.handleToggle); + } + + componentWillUnmount() { + ModalStore.removeModalListener(Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, this.handleToggle); + } + + handleToggle(value) { + this.setState({ + show: value + }); + } + + render() { + return ( + <GetLinkModal + show={this.state.show} + onHide={() => this.setState({show: false})} + title='Team Invite Link' + helpText='Send teammates the link below for them to sign-up to this team site.' + link={TeamStore.getCurrentInviteLink()} + /> + ); + } +} diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index c09477a69..56bc00a7e 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -1,14 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var ActionTypes = require('../utils/constants.jsx').ActionTypes; -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Client = require('../utils/client.jsx'); -var ModalStore = require('../stores/modal_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var ConfirmModal = require('./confirm_modal.jsx'); +import * as utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import * as Client from '../utils/client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import ModalStore from '../stores/modal_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import ConfirmModal from './confirm_modal.jsx'; const Modal = ReactBootstrap.Modal; @@ -22,6 +23,7 @@ export default class InviteMemberModal extends React.Component { this.addInviteFields = this.addInviteFields.bind(this); this.clearFields = this.clearFields.bind(this); this.removeInviteFields = this.removeInviteFields.bind(this); + this.showGetTeamInviteLinkModal = this.showGetTeamInviteLinkModal.bind(this); this.state = { show: false, @@ -31,7 +33,9 @@ export default class InviteMemberModal extends React.Component { firstNameErrors: {}, lastNameErrors: {}, emailEnabled: global.window.mm_config.SendEmailNotifications === 'true', - showConfirmModal: false + userCreationEnabled: global.window.mm_config.EnableUserCreation === 'true', + showConfirmModal: false, + isSendingEmails: false }; } @@ -89,10 +93,13 @@ export default class InviteMemberModal extends React.Component { var data = {}; data.invites = invites; + this.setState({isSendingEmails: true}); + Client.inviteMembers( data, () => { this.handleHide(false); + this.setState({isSendingEmails: false}); }, (err) => { if (err.message === 'This person is already on your team') { @@ -101,6 +108,8 @@ export default class InviteMemberModal extends React.Component { } else { this.setState({serverError: err.message}); } + + this.setState({isSendingEmails: false}); } ); } @@ -135,7 +144,7 @@ export default class InviteMemberModal extends React.Component { componentDidUpdate(prevProps, prevState) { if (!prevState.show && this.state.show) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 300); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); if ($(window).width() > 768) { $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); } @@ -181,6 +190,12 @@ export default class InviteMemberModal extends React.Component { this.setState({inviteIds: inviteIds, idCount: count}); } + showGetTeamInviteLinkModal() { + this.handleHide(false); + + EventHelpers.showGetTeamInviteLinkModal(); + } + render() { var currentUser = UserStore.getCurrentUser(); @@ -238,7 +253,7 @@ export default class InviteMemberModal extends React.Component { ref={'first_name' + index} placeholder='First name' maxLength='64' - disabled={!this.state.emailEnabled} + disabled={!this.state.emailEnabled || !this.state.userCreationEnabled} spellCheck='false' /> {firstNameError} @@ -252,7 +267,7 @@ export default class InviteMemberModal extends React.Component { ref={'last_name' + index} placeholder='Last name' maxLength='64' - disabled={!this.state.emailEnabled} + disabled={!this.state.emailEnabled || !this.state.userCreationEnabled} spellCheck='false' /> {lastNameError} @@ -271,7 +286,7 @@ export default class InviteMemberModal extends React.Component { className='form-control' placeholder='email@domain.com' maxLength='64' - disabled={!this.state.emailEnabled} + disabled={!this.state.emailEnabled || !this.state.userCreationEnabled} spellCheck='false' /> {emailError} @@ -289,12 +304,7 @@ export default class InviteMemberModal extends React.Component { var content = null; var sendButton = null; - var sendButtonLabel = 'Send Invitation'; - if (this.state.inviteIds.length > 1) { - sendButtonLabel = 'Send Invitations'; - } - - if (this.state.emailEnabled) { + if (this.state.emailEnabled && this.state.userCreationEnabled) { content = ( <div> {serverError} @@ -309,54 +319,68 @@ export default class InviteMemberModal extends React.Component { </div> ); - sendButton = - ( - <button - onClick={this.handleSubmit} - type='button' - className='btn btn-primary' - >{sendButtonLabel}</button> + var sendButtonLabel = 'Send Invitation'; + if (this.state.isSendingEmails) { + sendButtonLabel = ( + <span><i className='fa fa-spinner fa-spin' />{' Sending'}</span> ); - } else { + } else if (this.state.inviteIds.length > 1) { + sendButtonLabel = 'Send Invitations'; + } + + sendButton = ( + <button + onClick={this.handleSubmit} + type='button' + className='btn btn-primary' + disabled={this.state.isSendingEmails} + > + {sendButtonLabel} + </button> + ); + } else if (this.state.userCreationEnabled) { var teamInviteLink = null; if (currentUser && TeamStore.getCurrent().type === 'O') { - var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id; - var link = - ( - <a - href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={linkUrl} - onClick={() => this.handleHide(this, false)} - >Team Invite Link</a> + var link = ( + <a + href='#' + onClick={this.showGetTeamInviteLinkModal} + > + {'Team Invite Link'} + </a> ); teamInviteLink = ( <p> - You can also invite people using the {link}. + {'You can also invite people using the '}{link}{'.'} </p> ); } content = ( <div> - <p>Email is currently disabled for your team, and email invitations cannot be sent. Contact your system administrator to enable email and email invitations.</p> + <p>{'Email is currently disabled for your team, and email invitations cannot be sent. Contact your system administrator to enable email and email invitations.'}</p> {teamInviteLink} </div> ); + } else { + content = ( + <div> + <p>{'User creation has been disabled for your team. Please ask your team administrator for details.'}</p> + </div> + ); } return ( <div> <Modal - className='modal-invite-member' + dialogClassName='modal-invite-member' show={this.state.show} onHide={this.handleHide.bind(this, true)} enforceFocus={!this.state.showConfirmModal} + backdrop={this.state.isSendingEmails ? 'static' : true} > - <Modal.Header closeButton={true}> + <Modal.Header closeButton={!this.state.isSendingEmails}> <Modal.Title>{'Invite New Member'}</Modal.Title> </Modal.Header> <Modal.Body ref='modalBody'> @@ -370,6 +394,7 @@ export default class InviteMemberModal extends React.Component { type='button' className='btn btn-default' onClick={this.handleHide.bind(this, true)} + disabled={this.state.isSendingEmails} > {'Cancel'} </button> @@ -390,13 +415,6 @@ export default class InviteMemberModal extends React.Component { return null; } - - static show() { - AppDispatcher.handleViewAction({ - type: ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, - value: true - }); - } } InviteMemberModal.propTypes = { diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx index 2b9ce67ca..d87bd20ad 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const BrowserStore = require('../stores/browser_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; export default class Login extends React.Component { constructor(props) { @@ -125,7 +125,7 @@ export default class Login extends React.Component { let emailSignup; if (global.window.mm_config.EnableSignUpWithEmail === 'true') { emailSignup = ( - <div> + <div className='signup__email-container'> <div className={'form-group' + errorClass}> <input autoFocus={focusEmail} @@ -201,14 +201,12 @@ export default class Login extends React.Component { if (global.window.mm_config.EnableTeamCreation === 'true') { teamSignUp = ( <div className='margin--extra'> - <span>{'Want to create your own team? '} - <a - href='/' - className='signup-team-login' - > - {'Sign up now'} - </a> - </span> + <a + href='/' + className='signup-team-login' + > + {'Create a new team'} + </a> </div> ); } @@ -227,7 +225,7 @@ export default class Login extends React.Component { {emailSignup} {userSignUp} <div className='form-group margin--extra form-group--small'> - <span><a href='/find_team'>{'Find other teams'}</a></span> + <span><a href='/find_team'>{'Find your other teams'}</a></span> </div> {forgotPassword} {teamSignUp} diff --git a/web/react/components/member_list.jsx b/web/react/components/member_list.jsx index 0238c7920..8c6dc4209 100644 --- a/web/react/components/member_list.jsx +++ b/web/react/components/member_list.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var MemberListItem = require('./member_list_item.jsx'); +import MemberListItem from './member_list_item.jsx'; export default class MemberList extends React.Component { constructor(props) { diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx index 8251d67bc..a7273f280 100644 --- a/web/react/components/member_list_item.jsx +++ b/web/react/components/member_list_item.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); +import UserStore from '../stores/user_store.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class MemberListItem extends React.Component { constructor(props) { @@ -31,9 +31,7 @@ export default class MemberListItem extends React.Component { var timestamp = UserStore.getCurrentUser().update_at; var invite; - if (member.invited && this.props.handleInvite) { - invite = <span className='member-role'>Added</span>; - } else if (this.props.handleInvite) { + if (this.props.handleInvite) { invite = ( <a onClick={this.handleInvite} @@ -80,17 +78,15 @@ export default class MemberListItem extends React.Component { href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' data-toggle='dropdown' aria-expanded='true' > + <span className='fa fa-pencil'></span> <span className='text-capitalize'>{member.roles || 'Member'} </span> - <span className='caret'></span> </a> <ul className='dropdown-menu member-menu' role='menu' - aria-labelledby='channel_header_dropdown' > {makeAdminOption} {handleRemoveOption} @@ -98,7 +94,7 @@ export default class MemberListItem extends React.Component { </div> ); } else { - invite = <div className='member-role text-capitalize'>{member.roles || 'Member'}<span className='caret hidden'></span></div>; + invite = <div className='member-role text-capitalize'><span className='fa fa-pencil hidden'></span>{member.roles || 'Member'}</div>; } return ( @@ -110,7 +106,7 @@ export default class MemberListItem extends React.Component { height='36' width='36' /> - <div className='member-name'>{member.username}</div> + <div className='member-name'>{Utils.displayUsername(member.id)}</div> <div className='member-description'>{member.email}</div> </td> <td className='td--action lg'>{invite}</td> diff --git a/web/react/components/member_list_team.jsx b/web/react/components/member_list_team.jsx index cb2d0660b..f1c31131f 100644 --- a/web/react/components/member_list_team.jsx +++ b/web/react/components/member_list_team.jsx @@ -1,18 +1,57 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const MemberListTeamItem = require('./member_list_team_item.jsx'); +import MemberListTeamItem from './member_list_team_item.jsx'; +import UserStore from '../stores/user_store.jsx'; export default class MemberListTeam extends React.Component { + constructor(props) { + super(props); + + this.getUsers = this.getUsers.bind(this); + this.onChange = this.onChange.bind(this); + + this.state = { + users: this.getUsers() + }; + } + + componentDidMount() { + UserStore.addChangeListener(this.onChange); + } + + componentWillUnmount() { + UserStore.removeChangeListener(this.onChange); + } + + getUsers() { + const profiles = UserStore.getProfiles(); + const users = []; + + for (const id of Object.keys(profiles)) { + users.push(profiles[id]); + } + + users.sort((a, b) => a.username.localeCompare(b.username)); + + return users; + } + + onChange() { + this.setState({ + users: this.getUsers() + }); + } + render() { - const memberList = this.props.users.map(function makeListItem(user) { + const memberList = this.state.users.map((user) => { return ( <MemberListTeamItem key={user.id} user={user} /> ); - }, this); + }); return ( <table className='table more-table member-list-holder'> @@ -23,7 +62,3 @@ export default class MemberListTeam extends React.Component { ); } } - -MemberListTeam.propTypes = { - users: React.PropTypes.arrayOf(React.PropTypes.object).isRequired -}; diff --git a/web/react/components/member_list_team_item.jsx b/web/react/components/member_list_team_item.jsx index 1fa369068..7967c410d 100644 --- a/web/react/components/member_list_team_item.jsx +++ b/web/react/components/member_list_team_item.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const UserStore = require('../stores/user_store.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const Utils = require('../utils/utils.jsx'); +import UserStore from '../stores/user_store.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class MemberListTeamItem extends React.Component { constructor(props) { @@ -174,24 +174,22 @@ export default class MemberListTeamItem extends React.Component { height='36' width='36' /> - <span className='member-name'>{Utils.getDisplayName(user)}</span> + <span className='member-name'>{Utils.displayUsername(user.id)}</span> <span className='member-email'>{email}</span> <div className='dropdown member-drop'> <a href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' data-toggle='dropdown' aria-expanded='true' > + <span className='fa fa-pencil'></span> <span>{currentRoles} </span> - <span className='caret'></span> </a> <ul className='dropdown-menu member-menu' role='menu' - aria-labelledby='channel_header_dropdown' > {makeAdmin} {makeMember} diff --git a/web/react/components/mention.jsx b/web/react/components/mention.jsx deleted file mode 100644 index 050887c6f..000000000 --- a/web/react/components/mention.jsx +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); - -export default class Mention extends React.Component { - constructor(props) { - super(props); - - this.handleClick = this.handleClick.bind(this); - - this.state = null; - } - handleClick() { - this.props.handleClick(this.props.username); - } - render() { - var icon; - var timestamp = UserStore.getCurrentUser().update_at; - if (this.props.id === 'allmention' || this.props.id === 'channelmention') { - icon = <span><i className='mention-img fa fa-users fa-2x'></i></span>; - } else if (this.props.id == null) { - icon = <span><i className='mention-img fa fa-users fa-2x'></i></span>; - } else { - icon = ( - <span> - <img - className='mention-img' - src={'/api/v1/users/' + this.props.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} - /> - </span> - ); - } - return ( - <div - className={'mentions-name ' + this.props.isFocused} - id={this.props.id + '_mentions'} - onClick={this.handleClick} - onMouseEnter={this.props.handleMouseEnter} - > - <div className='pull-left'>{icon}</div> - <div className='pull-left mention-align'><span>@{this.props.username}</span><span className='mention-fullname'>{this.props.secondary_text}</span></div> - </div> - ); - } -} - -Mention.defaultProps = { - username: '', - id: '', - isFocused: '', - secondary_text: '' -}; -Mention.propTypes = { - handleClick: React.PropTypes.func.isRequired, - handleMouseEnter: React.PropTypes.func.isRequired, - username: React.PropTypes.string, - id: React.PropTypes.string, - isFocused: React.PropTypes.string, - secondary_text: React.PropTypes.string -}; diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx deleted file mode 100644 index 61a24c09c..000000000 --- a/web/react/components/mention_list.jsx +++ /dev/null @@ -1,276 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var SearchStore = require('../stores/search_store.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Mention = require('./mention.jsx'); - -var Constants = require('../utils/constants.jsx'); -var Utils = require('../utils/utils.jsx'); -var ActionTypes = Constants.ActionTypes; - -var MAX_HEIGHT_LIST = 292; -var MAX_ITEMS_IN_LIST = 25; -var ITEM_HEIGHT = 36; - -export default class MentionList extends React.Component { - constructor(props) { - super(props); - - this.onListenerChange = this.onListenerChange.bind(this); - this.handleClick = this.handleClick.bind(this); - this.handleMouseEnter = this.handleMouseEnter.bind(this); - this.getSelection = this.getSelection.bind(this); - this.addCurrentMention = this.addCurrentMention.bind(this); - this.addFirstMention = this.addFirstMention.bind(this); - this.isEmpty = this.isEmpty.bind(this); - this.scrollToMention = this.scrollToMention.bind(this); - this.onScroll = this.onScroll.bind(this); - this.onMentionListKey = this.onMentionListKey.bind(this); - this.onClick = this.onClick.bind(this); - - this.state = {excludeUsers: [], mentionText: '-1', selectedMention: 0, selectedUsername: ''}; - } - onScroll() { - if ($('.mentions--top').length) { - $('#reply_mention_tab .mentions--top').css({bottom: $(window).height() - $('.post-right__scroll #reply_textbox').offset().top}); - } - } - onMentionListKey(e) { - if (!this.isEmpty() && this.state.mentionText !== '-1' && (e.which === 13 || e.which === 9)) { - e.stopPropagation(); - e.preventDefault(); - this.addCurrentMention(); - } else if (!this.isEmpty() && this.state.mentionText !== '-1' && (e.which === 38 || e.which === 40)) { - e.stopPropagation(); - e.preventDefault(); - - if (e.which === 38) { - if (this.getSelection(this.state.selectedMention - 1)) { - this.setState({selectedMention: this.state.selectedMention - 1, selectedUsername: this.refs['mention' + (this.state.selectedMention - 1)].props.username}); - } - } else if (e.which === 40) { - if (this.getSelection(this.state.selectedMention + 1)) { - this.setState({selectedMention: this.state.selectedMention + 1, selectedUsername: this.refs['mention' + (this.state.selectedMention + 1)].props.username}); - } - } - - this.scrollToMention(e.which); - } - } - onClick(e) { - if (!($('#' + this.props.id).is(e.target) || $('#' + this.props.id).has(e.target).length || - ('mentionlist' in this.refs && $(ReactDOM.findDOMNode(this.refs.mentionlist)).has(e.target).length))) { - this.setState({mentionText: '-1'}); - } - } - componentDidMount() { - SearchStore.addMentionDataChangeListener(this.onListenerChange); - - $('.post-right__scroll').scroll(this.onScroll); - - $('body').on('keydown.mentionlist', '#' + this.props.id, this.onMentionListKey); - $(document).click(this.onClick); - } - componentWillUnmount() { - SearchStore.removeMentionDataChangeListener(this.onListenerChange); - $('body').off('keydown.mentionlist', '#' + this.props.id); - } - - /* - * This component is poorly designed, nessesitating some state modification - * in the componentDidUpdate function. This is generally discouraged as it - * is a performance issue and breaks with good react design. This component - * should be redesigned. - */ - componentDidUpdate() { - if (this.state.mentionText !== '-1') { - if (this.state.selectedUsername !== '' && (!this.getSelection(this.state.selectedMention) || this.state.selectedUsername !== this.refs['mention' + this.state.selectedMention].props.username)) { - var tempSelectedMention = -1; - var foundMatch = false; - while (tempSelectedMention < this.state.selectedMention && this.getSelection(++tempSelectedMention)) { - if (this.state.selectedUsername === this.refs['mention' + tempSelectedMention].props.username) { - this.setState({selectedMention: tempSelectedMention}); //eslint-disable-line react/no-did-update-set-state - foundMatch = true; - break; - } - } - if (this.getSelection(0) && !foundMatch) { - this.setState({selectedMention: 0, selectedUsername: this.refs.mention0.props.username}); //eslint-disable-line react/no-did-update-set-state - } - } - } else if (this.state.selectedMention !== 0) { - this.setState({selectedMention: 0, selectedUsername: ''}); //eslint-disable-line react/no-did-update-set-state - } - } - onListenerChange(id, mentionText) { - if (id !== this.props.id) { - return; - } - - var newState = this.state; - if (mentionText != null) { - newState.mentionText = mentionText; - } - - this.setState(newState); - } - handleClick(name) { - AppDispatcher.handleViewAction({ - type: ActionTypes.RECIEVED_ADD_MENTION, - id: this.props.id, - username: name - }); - - this.setState({mentionText: '-1'}); - } - handleMouseEnter(listId) { - this.setState({selectedMention: listId, selectedUsername: this.refs['mention' + listId].props.username}); - } - getSelection(listId) { - if (!this.refs['mention' + listId]) { - return false; - } - return true; - } - addCurrentMention() { - if (this.getSelection(this.state.selectedMention)) { - this.refs['mention' + this.state.selectedMention].handleClick(); - } else { - this.addFirstMention(); - } - } - addFirstMention() { - if (!this.refs.mention0) { - return; - } - this.refs.mention0.handleClick(); - } - isEmpty() { - return (!this.refs.mention0); - } - scrollToMention(keyPressed) { - var direction; - if (keyPressed === 38) { - direction = 'up'; - } else { - direction = 'down'; - } - var scrollAmount = 0; - - if (direction === 'up') { - scrollAmount = '-=' + ($('#' + this.refs['mention' + this.state.selectedMention].props.id + '_mentions').innerHeight() - 5); - } else if (direction === 'down') { - scrollAmount = '+=' + ($('#' + this.refs['mention' + this.state.selectedMention].props.id + '_mentions').innerHeight() - 5); - } - - $('#mentionsbox').animate({ - scrollTop: scrollAmount - }, 75); - } - render() { - var mentionText = this.state.mentionText; - if (mentionText === '-1') { - return null; - } - - var profiles = UserStore.getActiveOnlyProfiles(); - var users = []; - for (let id in profiles) { - if (profiles[id]) { - users.push(profiles[id]); - } - } - - var all = {}; - all.username = 'all'; - all.nickname = ''; - all.secondary_text = 'Notifies everyone in the team'; - all.id = 'allmention'; - users.push(all); - - var channel = {}; - channel.username = 'channel'; - channel.nickname = ''; - channel.secondary_text = 'Notifies everyone in the channel'; - channel.id = 'channelmention'; - users.push(channel); - - users.sort(function sortByUsername(a, b) { - if (a.username < b.username) { - return -1; - } - if (a.username > b.username) { - return 1; - } - return 0; - }); - var mentions = []; - var index = 0; - - for (var i = 0; i < users.length && index < MAX_ITEMS_IN_LIST; i++) { - if ((users[i].first_name && users[i].first_name.lastIndexOf(mentionText, 0) === 0) || - (users[i].last_name && users[i].last_name.lastIndexOf(mentionText, 0) === 0) || - users[i].username.lastIndexOf(mentionText, 0) === 0) { - let isFocused = ''; - 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={users[i].secondary_text} - id={users[i].id} - listId={index} - isFocused={isFocused} - handleMouseEnter={this.handleMouseEnter.bind(this, index)} - handleClick={this.handleClick} - /> - ); - index++; - } - } - - var numMentions = mentions.length; - - if (numMentions < 1) { - return null; - } - - var $mentionTab = $('#' + this.props.id); - var maxHeight = Math.min(MAX_HEIGHT_LIST, $mentionTab.offset().top - 10); - var style = { - height: Math.min(maxHeight, (numMentions * ITEM_HEIGHT) + 4), - width: $mentionTab.parent().width(), - bottom: $(window).height() - $mentionTab.offset().top, - left: $mentionTab.offset().left - }; - - return ( - <div - className='mentions--top' - style={style} - > - <div - ref='mentionlist' - className='mentions-box' - id='mentionsbox' - > - {mentions} - </div> - </div> - ); - } -} - -MentionList.propTypes = { - id: React.PropTypes.string -}; diff --git a/web/react/components/message_wrapper.jsx b/web/react/components/message_wrapper.jsx index 00c427c79..8e0380c06 100644 --- a/web/react/components/message_wrapper.jsx +++ b/web/react/components/message_wrapper.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var TextFormatting = require('../utils/text_formatting.jsx'); +import * as TextFormatting from '../utils/text_formatting.jsx'; export default class MessageWrapper extends React.Component { constructor(props) { diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index c4f831c2e..29512b9b7 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -1,12 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var asyncClient = require('../utils/async_client.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var LoadingScreen = require('./loading_screen.jsx'); -var NewChannelFlow = require('./new_channel_flow.jsx'); +import * as utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import LoadingScreen from './loading_screen.jsx'; +import NewChannelFlow from './new_channel_flow.jsx'; function getStateFromStores() { return { @@ -31,12 +31,12 @@ export default class MoreChannels extends React.Component { } componentDidMount() { ChannelStore.addMoreChangeListener(this.onListenerChange); - $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', function shown() { - asyncClient.getMoreChannels(true); + $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', () => { + AsyncClient.getMoreChannels(true); }); var self = this; - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', function show(e) { + $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', (e) => { var button = e.relatedTarget; self.setState({channelType: $(button).attr('data-channeltype')}); }); @@ -46,22 +46,22 @@ export default class MoreChannels extends React.Component { } onListenerChange() { var newState = getStateFromStores(); - if (!utils.areStatesEqual(newState.channels, this.state.channels)) { + if (!utils.areObjectsEqual(newState.channels, this.state.channels)) { this.setState(newState); } } handleJoin(channel, channelIndex) { this.setState({joiningChannel: channelIndex}); client.joinChannel(channel.id, - function joinSuccess() { + () => { $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - asyncClient.getChannel(channel.id); + AsyncClient.getChannel(channel.id); utils.switchChannel(channel); this.setState({joiningChannel: -1}); - }.bind(this), - function joinFail(err) { + }, + (err) => { this.setState({joiningChannel: -1, serverError: err.message}); - }.bind(this) + } ); } handleNewChannel() { diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index d1265f67e..3661b19e6 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -2,8 +2,8 @@ // See License.txt for license information. const Modal = ReactBootstrap.Modal; -const UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); +import UserStore from '../stores/user_store.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class MoreDirectChannels extends React.Component { constructor(props) { @@ -47,6 +47,21 @@ export default class MoreDirectChannels extends React.Component { UserStore.addChangeListener(this.handleUserChange); } + componentDidUpdate(prevProps) { + if (!prevProps.show && this.props.show) { + this.onShow(); + } + } + + onShow() { + if (Utils.isMobile()) { + $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 250); + } else { + $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300); + } + } + handleFilterChange() { const filter = ReactDOM.findDOMNode(this.refs.filter).value; @@ -164,15 +179,6 @@ export default class MoreDirectChannels extends React.Component { ); } - componentDidUpdate(prevProps) { - if (!prevProps.show && this.props.show) { - $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300); - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); - } - } - } - render() { if (!this.props.show) { return null; @@ -217,8 +223,8 @@ export default class MoreDirectChannels extends React.Component { <Modal.Header closeButton={true}> <Modal.Title>{'Direct Messages'}</Modal.Title> </Modal.Header> - <Modal.Body> - <div className='row filter-row'> + <Modal.Body ref='modalBody'> + <div className='filter-row'> <div className='col-sm-6'> <input ref='filter' diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx index ccf8a2445..78b67a216 100644 --- a/web/react/components/msg_typing.jsx +++ b/web/react/components/msg_typing.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const SocketStore = require('../stores/socket_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); +import SocketStore from '../stores/socket_store.jsx'; +import UserStore from '../stores/user_store.jsx'; -const Constants = require('../utils/constants.jsx'); +import Constants from '../utils/constants.jsx'; const SocketEvents = Constants.SocketEvents; export default class MsgTyping extends React.Component { @@ -74,11 +74,12 @@ export default class MsgTyping extends React.Component { case 1: text = users[0] + ' is typing...'; break; - default: + default: { const last = users.pop(); text = users.join(', ') + ' and ' + last + ' are typing...'; break; } + } this.setState({text}); } diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index af29f219e..ae14fca2f 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -1,23 +1,28 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); -const MessageWrapper = require('./message_wrapper.jsx'); -const NotifyCounts = require('./notify_counts.jsx'); -const ChannelMembersModal = require('./channel_members_modal.jsx'); -const ChannelInviteModal = require('./channel_invite_modal.jsx'); +import EditChannelHeaderModal from './edit_channel_header_modal.jsx'; +import EditChannelPurposeModal from './edit_channel_purpose_modal.jsx'; +import MessageWrapper from './message_wrapper.jsx'; +import NotifyCounts from './notify_counts.jsx'; +import ChannelMembersModal from './channel_members_modal.jsx'; +import ChannelInfoModal from './channel_info_modal.jsx'; +import ChannelInviteModal from './channel_invite_modal.jsx'; +import ChannelNotificationsModal from './channel_notifications_modal.jsx'; +import DeleteChannelModal from './delete_channel_modal.jsx'; +import ToggleModalButton from './toggle_modal_button.jsx'; -const UserStore = require('../stores/user_store.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -const TeamStore = require('../stores/team_store.jsx'); +import UserStore from '../stores/user_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const Utils = require('../utils/utils.jsx'); +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Utils from '../utils/utils.jsx'; -const Constants = require('../utils/constants.jsx'); +import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; const Popover = ReactBootstrap.Popover; const OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -28,13 +33,17 @@ export default class Navbar extends React.Component { this.onChange = this.onChange.bind(this); this.handleLeave = this.handleLeave.bind(this); + this.showSearch = this.showSearch.bind(this); + + this.showEditChannelHeaderModal = this.showEditChannelHeaderModal.bind(this); + this.createCollapseButtons = this.createCollapseButtons.bind(this); this.createDropdown = this.createDropdown.bind(this); const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; + state.showEditChannelHeaderModal = false; state.showMembersModal = false; - state.showInviteModal = false; this.state = state; } getStateFromStores() { @@ -96,23 +105,36 @@ export default class Navbar extends React.Component { $('.inner__wrap').toggleClass('move--left-small'); $('.sidebar--menu').toggleClass('move--left'); } + showSearch() { + AppDispatcher.handleServerAction({ + type: ActionTypes.SHOW_SEARCH + }); + } onChange() { this.setState(this.getStateFromStores()); $('#navbar .navbar-brand .description').popover({placement: 'bottom', trigger: 'click', html: true}); } + showEditChannelHeaderModal() { + // this can't be done using a ToggleModalButton because we can't use one inside an OverlayTrigger + if (this.refs.headerOverlay) { + this.refs.headerOverlay.hide(); + } + + this.setState({ + showEditChannelHeaderModal: true + }); + } createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent) { if (channel) { var viewInfoOption = ( <li role='presentation'> - <a + <ToggleModalButton role='menuitem' - data-toggle='modal' - data-target='#channel_info' - data-channelid={channel.id} - href='#' + dialogType={ChannelInfoModal} + dialogProps={{channel}} > {'View Info'} - </a> + </ToggleModalButton> </li> ); @@ -121,11 +143,7 @@ export default class Navbar extends React.Component { <a role='menuitem' href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} + onClick={this.showEditChannelHeaderModal} > {'Set Channel Header...'} </a> @@ -152,13 +170,13 @@ export default class Navbar extends React.Component { if (!isDirect && !ChannelStore.isDefault(channel)) { addMembersOption = ( <li role='presentation'> - <a + <ToggleModalButton role='menuitem' - href='#' - onClick={() => this.setState({showInviteModal: false})} + dialogType={ChannelInviteModal} + dialogProps={{channel}} > {'Add Members'} - </a> + </ToggleModalButton> </li> ); @@ -178,18 +196,32 @@ export default class Navbar extends React.Component { var manageMembersOption; var renameChannelOption; var deleteChannelOption; - if (!isDirect && isAdmin && !ChannelStore.isDefault(channel)) { - manageMembersOption = ( - <li role='presentation'> - <a - role='menuitem' - href='#' - onClick={() => this.setState({showMembersModal: true})} - > - {'Manage Members'} - </a> - </li> - ); + if (!isDirect && isAdmin) { + if (!ChannelStore.isDefault(channel)) { + manageMembersOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={() => this.setState({showMembersModal: true})} + > + {'Manage Members'} + </a> + </li> + ); + + deleteChannelOption = ( + <li role='presentation'> + <ToggleModalButton + role='menuitem' + dialogType={DeleteChannelModal} + dialogProps={{channel}} + > + {'Delete Channel...'} + </ToggleModalButton> + </li> + ); + } renameChannelOption = ( <li role='presentation'> @@ -206,37 +238,19 @@ export default class Navbar extends React.Component { </a> </li> ); - - deleteChannelOption = ( - <li role='presentation'> - <a - role='menuitem' - href='#' - data-toggle='modal' - data-target='#delete_channel' - data-title={channel.display_name} - data-channelid={channel.id} - > - {'Delete Channel...'} - </a> - </li> - ); } var notificationPreferenceOption; if (!isDirect) { notificationPreferenceOption = ( <li role='presentation'> - <a + <ToggleModalButton role='menuitem' - href='#' - data-toggle='modal' - data-target='#channel_notifications' - data-title={channel.display_name} - data-channelid={channel.id} + dialogType={ChannelNotificationsModal} + dialogProps={{channel}} > {'Notification Preferences'} - </a> + </ToggleModalButton> </li> ); } @@ -245,6 +259,7 @@ export default class Navbar extends React.Component { <div className='navbar-brand'> <div className='dropdown'> <OverlayTrigger + ref='headerOverlay' trigger='click' placement='bottom' overlay={popoverContent} @@ -257,7 +272,6 @@ export default class Navbar extends React.Component { href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' data-toggle='dropdown' aria-expanded='true' > @@ -267,7 +281,6 @@ export default class Navbar extends React.Component { <ul className='dropdown-menu' role='menu' - aria-labelledby='channel_header_dropdown' > {viewInfoOption} {addMembersOption} @@ -354,6 +367,9 @@ export default class Navbar extends React.Component { var isAdmin = false; var isDirect = false; + var editChannelHeaderModal = null; + var editChannelPurposeModal = null; + if (channel) { popoverContent = ( <Popover @@ -396,11 +412,7 @@ export default class Navbar extends React.Component { <br/> <a href='#' - data-toggle='modal' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - data-target='#edit_channel' + onClick={this.showEditChannelHeaderModal} > {'Click here'} </a> @@ -409,10 +421,36 @@ export default class Navbar extends React.Component { </Popover> ); } + + editChannelHeaderModal = ( + <EditChannelHeaderModal + show={this.state.showEditChannelHeaderModal} + onHide={() => this.setState({showEditChannelHeaderModal: false})} + channel={channel} + /> + ); + + editChannelPurposeModal = ( + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + ); } var collapseButtons = this.createCollapseButtons(currentId); + const searchButton = ( + <button + type='button' + className='navbar-toggle pull-right' + onClick={this.showSearch} + > + <span className='glyphicon glyphicon-search icon--white' /> + </button> + ); + var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent); return ( @@ -424,22 +462,17 @@ export default class Navbar extends React.Component { <div className='container-fluid theme'> <div className='navbar-header'> {collapseButtons} + {searchButton} {channelMenuDropdown} </div> </div> </nav> - <EditChannelPurposeModal - show={this.state.showEditChannelPurposeModal} - onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} - channel={channel} - /> + {editChannelHeaderModal} + {editChannelPurposeModal} <ChannelMembersModal show={this.state.showMembersModal} onModalDismissed={() => this.setState({showMembersModal: false})} - /> - <ChannelInviteModal - show={this.state.showInviteModal} - onModalDismissed={() => this.setState({showInviteModal: false})} + channel={{channel}} /> </div> ); diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 0b755f377..d4ec5a5f5 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -1,16 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; -var AboutBuildModal = require('./about_build_modal.jsx'); -var InviteMemberModal = require('./invite_member_modal.jsx'); -var UserSettingsModal = require('./user_settings/user_settings_modal.jsx'); +import AboutBuildModal from './about_build_modal.jsx'; +import TeamMembersModal from './team_members_modal.jsx'; +import ToggleModalButton from './toggle_modal_button.jsx'; +import UserSettingsModal from './user_settings/user_settings_modal.jsx'; -var Constants = require('../utils/constants.jsx'); +import Constants from '../utils/constants.jsx'; function getStateFromStores() { const teams = []; @@ -70,7 +72,7 @@ export default class NavbarDropdown extends React.Component { } onListenerChange() { var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } @@ -93,7 +95,7 @@ export default class NavbarDropdown extends React.Component { <li> <a href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > {'Invite New Member'} </a> @@ -105,10 +107,7 @@ export default class NavbarDropdown extends React.Component { <li> <a href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} + onClick={EventHelpers.showGetTeamInviteLinkModal} > {'Get Team Invite Link'} </a> @@ -120,13 +119,9 @@ export default class NavbarDropdown extends React.Component { if (isAdmin) { manageLink = ( <li> - <a - href='#' - data-toggle='modal' - data-target='#team_members' - > + <ToggleModalButton dialogType={TeamMembersModal}> {'Manage Members'} - </a> + </ToggleModalButton> </li> ); @@ -189,6 +184,34 @@ export default class NavbarDropdown extends React.Component { ); } + let helpLink = null; + if (global.window.mm_config.HelpLink) { + helpLink = ( + <li> + <a + target='_blank' + href={global.window.mm_config.HelpLink} + > + {'Help'} + </a> + </li> + ); + } + + let reportLink = null; + if (global.window.mm_config.ReportAProblemLink) { + reportLink = ( + <li> + <a + target='_blank' + href={global.window.mm_config.ReportAProblemLink} + > + {'Report a Problem'} + </a> + </li> + ); + } + return ( <ul className='nav navbar-nav navbar-right'> <li @@ -235,22 +258,8 @@ export default class NavbarDropdown extends React.Component { {sysAdminLink} {teams} <li className='divider'></li> - <li> - <a - target='_blank' - href='/static/help/help.html' - > - {'Help'} - </a> - </li> - <li> - <a - target='_blank' - href='/static/help/report_problem.html' - > - {'Report a Problem'} - </a> - </li> + {helpLink} + {reportLink} <li> <a href='#' diff --git a/web/react/components/new_channel_flow.jsx b/web/react/components/new_channel_flow.jsx index d6280d118..3a114aa19 100644 --- a/web/react/components/new_channel_flow.jsx +++ b/web/react/components/new_channel_flow.jsx @@ -1,13 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var Client = require('../utils/client.jsx'); -var UserStore = require('../stores/user_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; -var NewChannelModal = require('./new_channel_modal.jsx'); -var ChangeURLModal = require('./change_url_modal.jsx'); +import NewChannelModal from './new_channel_modal.jsx'; +import ChangeURLModal from './change_url_modal.jsx'; const SHOW_NEW_CHANNEL = 1; const SHOW_EDIT_URL = 2; diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx index c0cea496f..70fe10eef 100644 --- a/web/react/components/new_channel_modal.jsx +++ b/web/react/components/new_channel_modal.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; var Modal = ReactBootstrap.Modal; export default class NewChannelModal extends React.Component { @@ -115,7 +115,7 @@ export default class NewChannelModal extends React.Component { type='text' ref='display_name' className='form-control' - placeholder='Ex: "Bugs", "Marketing", "办公室恋情"' + placeholder='E.g.: "Bugs", "Marketing", "办公室恋情"' maxLength='22' value={this.props.channelData.displayName} autoFocus={true} diff --git a/web/react/components/notify_counts.jsx b/web/react/components/notify_counts.jsx index 54b9e4289..19b81556b 100644 --- a/web/react/components/notify_counts.jsx +++ b/web/react/components/notify_counts.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); +import * as utils from '../utils/utils.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; function getCountsStateFromStores() { var count = 0; @@ -39,7 +39,7 @@ export default class NotifyCounts extends React.Component { } onListenerChange() { var newState = getCountsStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/password_reset.jsx b/web/react/components/password_reset.jsx index 54d126144..4c9bb6310 100644 --- a/web/react/components/password_reset.jsx +++ b/web/react/components/password_reset.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PasswordResetSendLink = require('./password_reset_send_link.jsx'); -var PasswordResetForm = require('./password_reset_form.jsx'); +import PasswordResetSendLink from './password_reset_send_link.jsx'; +import PasswordResetForm from './password_reset_form.jsx'; export default class PasswordReset extends React.Component { constructor(props) { diff --git a/web/react/components/password_reset_form.jsx b/web/react/components/password_reset_form.jsx index b452c40b7..812911569 100644 --- a/web/react/components/password_reset_form.jsx +++ b/web/react/components/password_reset_form.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var client = require('../utils/client.jsx'); +import * as client from '../utils/client.jsx'; export default class PasswordResetForm extends React.Component { constructor(props) { diff --git a/web/react/components/password_reset_send_link.jsx b/web/react/components/password_reset_send_link.jsx index 8f1890705..051b8b02c 100644 --- a/web/react/components/password_reset_send_link.jsx +++ b/web/react/components/password_reset_send_link.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; export default class PasswordResetSendLink extends React.Component { constructor(props) { diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index f3c0fa0b4..f4cb542e4 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -1,12 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); +import UserStore from '../stores/user_store.jsx'; var Popover = ReactBootstrap.Popover; var Overlay = ReactBootstrap.Overlay; -const Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; -const ChannelStore = require('../stores/channel_store.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; export default class PopoverListMembers extends React.Component { constructor(props) { @@ -68,9 +69,7 @@ export default class PopoverListMembers extends React.Component { } render() { - let popoverHtml = []; - let count = 0; - let countText = '-'; + const popoverHtml = []; const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); const currentUserId = UserStore.getCurrentId(); @@ -78,35 +77,13 @@ export default class PopoverListMembers extends React.Component { if (members && teamMembers) { members.sort((a, b) => { - return a.username.localeCompare(b.username); + const aName = Utils.displayUsername(a.id); + const bName = Utils.displayUsername(b.id); + + return aName.localeCompare(bName); }); 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 = ( @@ -120,7 +97,12 @@ export default class PopoverListMembers extends React.Component { ); } - if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { + let name = ''; + if (teamMembers[m.username]) { + name = Utils.displayUsername(teamMembers[m.username].id); + } + + if (name && teamMembers[m.username].delete_at <= 0) { popoverHtml.push( <div className='text-nowrap' @@ -137,7 +119,7 @@ export default class PopoverListMembers extends React.Component { <div className='more-name' > - {m.username} + {name} </div> </div> <div @@ -147,15 +129,22 @@ export default class PopoverListMembers extends React.Component { </div> </div> ); - count++; } }); + } - if (count > 20) { - countText = '20+'; - } else if (count > 0) { - countText = count.toString(); - } + let count = this.props.memberCount; + let countText = '-'; + + // fall back to checking the length of the member list if the count isn't set + if (!count && members) { + count = members.length; + } + + if (count > Constants.MAX_CHANNEL_POPOVER_COUNT) { + countText = Constants.MAX_CHANNEL_POPOVER_COUNT + '+'; + } else if (count > 0) { + countText = count.toString(); } return ( @@ -195,5 +184,6 @@ export default class PopoverListMembers extends React.Component { PopoverListMembers.propTypes = { members: React.PropTypes.array.isRequired, + memberCount: React.PropTypes.number, channelId: React.PropTypes.string.isRequired }; diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index c3c5b3e0b..695d7daef 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -1,19 +1,17 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostHeader = require('./post_header.jsx'); -var PostBody = require('./post_body.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var PostStore = require('../stores/post_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); +import PostHeader from './post_header.jsx'; +import PostBody from './post_body.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import * as client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; var ActionTypes = Constants.ActionTypes; -var utils = require('../utils/utils.jsx'); - -var PostInfo = require('./post_info.jsx'); +import * as utils from '../utils/utils.jsx'; export default class Post extends React.Component { constructor(props) { @@ -77,7 +75,7 @@ export default class Post extends React.Component { this.forceUpdate(); } shouldComponentUpdate(nextProps) { - if (!utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } @@ -89,10 +87,18 @@ export default class Post extends React.Component { return true; } + if (nextProps.displayNameType !== this.props.displayNameType) { + return true; + } + if (this.getCommentCount(nextProps) !== this.getCommentCount(this.props)) { return true; } + if (nextProps.shouldHighlight !== this.props.shouldHighlight) { + return true; + } + return false; } getCommentCount(props) { @@ -107,7 +113,7 @@ export default class Post extends React.Component { } else { commentRootId = post.id; } - for (let postId in posts) { + for (const postId in posts) { if (posts[postId].root_id === commentRootId) { commentCount += 1; } @@ -116,68 +122,79 @@ export default class Post extends React.Component { return commentCount; } render() { - var post = this.props.post; - var parentPost = this.props.parentPost; - var posts = this.props.posts; + const post = this.props.post; + const parentPost = this.props.parentPost; + const posts = this.props.posts; if (!post.props) { post.props = {}; } - var type = 'Post'; + let type = 'Post'; if (post.root_id && post.root_id.length > 0) { type = 'Comment'; } const commentCount = this.getCommentCount(this.props); - var rootUser; + let rootUser; if (this.props.sameRoot) { rootUser = 'same--root'; } else { rootUser = 'other--root'; } - var postType = ''; + let postType = ''; if (type !== 'Post') { postType = 'post--comment'; + } else if (commentCount > 0) { + postType = 'post--root'; } - var currentUserCss = ''; - if (UserStore.getCurrentId() === post.user_id && !post.props.from_webhook) { + let currentUserCss = ''; + if (UserStore.getCurrentId() === post.user_id && !post.props.from_webhook && !utils.isSystemMessage(post)) { currentUserCss = 'current--user'; } - var userProfile = UserStore.getProfile(post.user_id); + const userProfile = UserStore.getProfile(post.user_id); - var timestamp = UserStore.getCurrentUser().update_at; + let timestamp = UserStore.getCurrentUser().update_at; if (userProfile) { timestamp = userProfile.update_at; } - var sameUserClass = ''; + let sameUserClass = ''; if (this.props.sameUser) { sameUserClass = 'same--user'; } - var profilePic = null; + let shouldHighlightClass = ''; + if (this.props.shouldHighlight) { + shouldHighlightClass = 'post--highlight'; + } + + let systemMessageClass = ''; + if (utils.isSystemMessage(post)) { + systemMessageClass = 'post--system'; + } + + let profilePic = null; if (!this.props.hideProfilePic) { let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { if (post.props.override_icon_url) { src = post.props.override_icon_url; } + } else if (utils.isSystemMessage(post)) { + src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE; } profilePic = ( - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={src} - height='36' - width='36' - /> - </div> + <img + src={src} + height='36' + width='36' + /> ); } @@ -185,34 +202,28 @@ export default class Post extends React.Component { <div> <div id={'post_' + post.id} - className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss} + className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss + ' ' + shouldHighlightClass + ' ' + systemMessageClass} > - {profilePic} <div className='post__content'> - <PostHeader - ref='header' - post={post} - sameRoot={this.props.sameRoot} - commentCount={commentCount} - handleCommentClick={this.handleCommentClick} - isLastComment={this.props.isLastComment} - /> - <PostBody - post={post} - sameRoot={this.props.sameRoot} - parentPost={parentPost} - posts={posts} - handleCommentClick={this.handleCommentClick} - retryPost={this.retryPost} - /> - <PostInfo - ref='info' - post={post} - sameRoot={this.props.sameRoot} - commentCount={commentCount} - handleCommentClick={this.handleCommentClick} - allowReply='true' - /> + <div className='post__img'>{profilePic}</div> + <div> + <PostHeader + ref='header' + post={post} + sameRoot={this.props.sameRoot} + commentCount={commentCount} + handleCommentClick={this.handleCommentClick} + isLastComment={this.props.isLastComment} + /> + <PostBody + post={post} + sameRoot={this.props.sameRoot} + parentPost={parentPost} + posts={posts} + handleCommentClick={this.handleCommentClick} + retryPost={this.retryPost} + /> + </div> </div> </div> </div> @@ -227,5 +238,7 @@ Post.propTypes = { sameUser: React.PropTypes.bool, sameRoot: React.PropTypes.bool, hideProfilePic: React.PropTypes.bool, - isLastComment: React.PropTypes.bool + isLastComment: React.PropTypes.bool, + shouldHighlight: React.PropTypes.bool, + displayNameType: React.PropTypes.string }; diff --git a/web/react/components/post_attachment.jsx b/web/react/components/post_attachment.jsx index cf65dfbfb..676bc91af 100644 --- a/web/react/components/post_attachment.jsx +++ b/web/react/components/post_attachment.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const TextFormatting = require('../utils/text_formatting.jsx'); +import * as TextFormatting from '../utils/text_formatting.jsx'; export default class PostAttachment extends React.Component { constructor(props) { diff --git a/web/react/components/post_attachment_list.jsx b/web/react/components/post_attachment_list.jsx index 03b866656..9c6700e2d 100644 --- a/web/react/components/post_attachment_list.jsx +++ b/web/react/components/post_attachment_list.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const PostAttachment = require('./post_attachment.jsx'); +import PostAttachment from './post_attachment.jsx'; export default class PostAttachmentList extends React.Component { constructor(props) { diff --git a/web/react/components/post_attachment_oembed.jsx b/web/react/components/post_attachment_oembed.jsx new file mode 100644 index 000000000..4b12ee95e --- /dev/null +++ b/web/react/components/post_attachment_oembed.jsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default class PostAttachmentOEmbed extends React.Component { + constructor(props) { + super(props); + this.fetchData = this.fetchData.bind(this); + + this.isLoading = false; + } + + componentWillMount() { + this.setState({data: {}}); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.link !== this.props.link) { + this.isLoading = false; + this.fetchData(nextProps.link); + } + } + + componentDidMount() { + this.fetchData(this.props.link); + } + + fetchData(link) { + if (!this.isLoading) { + this.isLoading = true; + let url = 'https://noembed.com/embed?nowrap=on'; + url += '&url=' + encodeURIComponent(link); + url += '&maxheight=' + this.props.provider.height; + return $.ajax({ + url, + dataType: 'jsonp', + success: (result) => { + this.isLoading = false; + if (result.error) { + this.setState({data: {}}); + } else { + this.setState({data: result}); + } + }, + error: () => { + this.setState({data: {}}); + } + }); + } + } + + render() { + let data = {}; + let content; + if ($.isEmptyObject(this.state.data)) { + content = <div style={{height: this.props.provider.height}}/>; + } else { + data = this.state.data; + content = ( + <div + style={{height: this.props.provider.height}} + dangerouslySetInnerHTML={{__html: data.html}} + /> + ); + } + + return ( + <div + className='attachment attachment--oembed' + ref='attachment' + > + <div className='attachment__content'> + <div + className={'clearfix attachment__container'} + > + <h1 + className='attachment__title' + > + <a + className='attachment__title-link' + href={data.url} + target='_blank' + > + {data.title} + </a> + </h1> + <div > + <div + className={'attachment__body attachment__body--no_thumb'} + > + {content} + </div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachmentOEmbed.propTypes = { + link: React.PropTypes.string.isRequired, + provider: React.PropTypes.object.isRequired +}; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 61a0c3e2d..dcbe56399 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -1,26 +1,30 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const FileAttachmentList = require('./file_attachment_list.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); -const Constants = require('../utils/constants.jsx'); -const TextFormatting = require('../utils/text_formatting.jsx'); -const twemoji = require('twemoji'); -const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); +import FileAttachmentList from './file_attachment_list.jsx'; +import UserStore from '../stores/user_store.jsx'; +import * as Utils from '../utils/utils.jsx'; +import * as Emoji from '../utils/emoticons.jsx'; +import Constants from '../utils/constants.jsx'; +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; +import * as TextFormatting from '../utils/text_formatting.jsx'; +import twemoji from 'twemoji'; +import PostBodyAdditionalContent from './post_body_additional_content.jsx'; + +import providers from './providers.json'; export default class PostBody extends React.Component { constructor(props) { super(props); this.receivedYoutubeData = false; - this.isGifLoading = false; + this.isImgLoading = false; this.handleUserChange = this.handleUserChange.bind(this); this.parseEmojis = this.parseEmojis.bind(this); this.createEmbed = this.createEmbed.bind(this); - this.createGifEmbed = this.createGifEmbed.bind(this); - this.loadGif = this.loadGif.bind(this); + this.createImageEmbed = this.createImageEmbed.bind(this); + this.loadImg = this.loadImg.bind(this); this.createYoutubeEmbed = this.createYoutubeEmbed.bind(this); const linkData = Utils.extractLinks(this.props.post.message); @@ -29,6 +33,7 @@ export default class PostBody extends React.Component { this.state = { links: linkData.links, message: linkData.text, + post: this.props.post, hasUserProfiles: profiles && Object.keys(profiles).length > 1 }; } @@ -49,7 +54,17 @@ export default class PostBody extends React.Component { } parseEmojis() { - twemoji.parse(ReactDOM.findDOMNode(this), {size: Constants.EMOJI_SIZE}); + twemoji.parse(ReactDOM.findDOMNode(this), { + className: 'emoji twemoji', + base: '', + folder: Emoji.getImagePathForEmoticon() + }); + } + + componentWillMount() { + if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) { + this.embed = this.createEmbed(this.state.links[0]); + } } componentDidMount() { @@ -76,47 +91,86 @@ export default class PostBody extends React.Component { componentWillReceiveProps(nextProps) { const linkData = Utils.extractLinks(nextProps.post.message); + if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) { + this.embed = this.createEmbed(linkData.links[0]); + } this.setState({links: linkData.links, message: linkData.text}); } createEmbed(link) { - let embed = this.createYoutubeEmbed(link); + const post = this.state.post; + + if (!link) { + if (post.type === 'oEmbed') { + post.props.oEmbedLink = ''; + post.type = ''; + } + return null; + } + + const trimmedLink = link.trim(); + + if (Utils.isFeatureEnabled(PreReleaseFeatures.EMBED_PREVIEW)) { + const provider = this.getOembedProvider(trimmedLink); + if (provider != null) { + post.props.oEmbedLink = trimmedLink; + post.type = 'oEmbed'; + this.setState({post, provider}); + return ''; + } + } + + const embed = this.createYoutubeEmbed(link); if (embed != null) { return embed; } - embed = this.createGifEmbed(link); + for (let i = 0; i < Constants.IMAGE_TYPES.length; i++) { + const imageType = Constants.IMAGE_TYPES[i]; + const suffix = link.substring(link.length - (imageType.length + 1)); + if (suffix === '.' + imageType || suffix === '=' + imageType) { + return this.createImageEmbed(link, this.state.imgLoaded); + } + } + + return null; + } - return embed; + getOembedProvider(link) { + for (let i = 0; i < providers.length; i++) { + for (let j = 0; j < providers[i].patterns.length; j++) { + if (link.match(providers[i].patterns[j])) { + return providers[i]; + } + } + } + return null; } - loadGif(src) { - if (this.isGifLoading) { + loadImg(src) { + if (this.isImgLoading) { return; } - this.isGifLoading = true; + this.isImgLoading = true; - const gif = new Image(); - gif.onload = ( + const img = new Image(); + img.onload = ( () => { - this.setState({gifLoaded: true}); + this.embed = this.createImageEmbed(src, true); + this.setState({imgLoaded: true}); } ); - gif.src = src; + img.src = src; } - createGifEmbed(link) { - if (link.substring(link.length - 4) !== '.gif') { - return null; - } - - if (!this.state.gifLoaded) { - this.loadGif(link); + createImageEmbed(link, isLoaded) { + if (!isLoaded) { + this.loadImg(link); return ( <img - className='gif-div placeholder' + className='img-div placeholder' height='500px' /> ); @@ -124,7 +178,7 @@ export default class PostBody extends React.Component { return ( <img - className='gif-div' + className='img-div' src={link} /> ); @@ -133,7 +187,7 @@ export default class PostBody extends React.Component { handleYoutubeTime(link) { const timeRegex = /[\\?&]t=([0-9hms]+)/; - const time = link.trim().match(timeRegex); + const time = link.match(timeRegex); if (!time || !time[1]) { return ''; } @@ -160,14 +214,14 @@ export default class PostBody extends React.Component { } createYoutubeEmbed(link) { - const ytRegex = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|watch\?(?:[a-zA-Z-_]+=[a-zA-Z0-9-_]+&)+v=)([^#\&\?]*).*/; + const ytRegex = /(?:http|https):\/\/(?:www\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(\/u\/\w\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^\/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#\&\?]*)/; const match = link.trim().match(ytRegex); - if (!match || match[1].length !== 11) { + if (!match || match[2].length !== 11) { return null; } - const youtubeId = match[1]; + const youtubeId = match[2]; const time = this.handleYoutubeTime(link); function onClick(e) { @@ -212,7 +266,7 @@ export default class PostBody extends React.Component { } return ( - <div className='post-comment'> + <div> <h4> <span className='video-type'>{header}</span> <span className='video-title'><a href={link}>{this.state.youtubeTitle}</a></span> @@ -255,7 +309,15 @@ export default class PostBody extends React.Component { let apostrophe = ''; let name = '...'; if (profile != null) { - if (profile.username.slice(-1) === 's') { + let username = profile.username; + if (parentPost.props && + parentPost.props.from_webhook && + parentPost.props.override_username && + global.window.mm_config.EnablePostUsernameOverride === 'true') { + username = parentPost.props.override_username; + } + + if (username.slice(-1) === 's') { apostrophe = '\''; } else { apostrophe = '\'s'; @@ -263,9 +325,9 @@ export default class PostBody extends React.Component { name = ( <a className='theme' - onClick={Utils.searchForTerm.bind(null, profile.username)} + onClick={Utils.searchForTerm.bind(null, username)} > - {profile.username} + {username} </a> ); } @@ -284,7 +346,7 @@ export default class PostBody extends React.Component { } comment = ( - <p className='post-link'> + <div className='post__link'> <span> {'Commented on '}{name}{apostrophe}{' message: '} <a @@ -294,15 +356,13 @@ export default class PostBody extends React.Component { {message} </a> </span> - </p> + </div> ); - - postClass += ' post-comment'; } let loading; if (post.state === Constants.POST_FAILED) { - postClass += ' post-fail'; + postClass += ' post--fail'; loading = ( <a className='theme post-retry pull-right' @@ -322,11 +382,6 @@ export default class PostBody extends React.Component { ); } - let embed; - if (filenames.length === 0 && this.state.links && this.state.links.length > 0) { - embed = this.createEmbed(this.state.links[0]); - } - let fileAttachmentHolder = ''; if (filenames && filenames.length > 0) { fileAttachmentHolder = ( @@ -339,25 +394,28 @@ export default class PostBody extends React.Component { } return ( - <div className='post-body'> + <div> {comment} - <div - key={`${post.id}_message`} - id={`${post.id}_message`} - className={postClass} - > - {loading} - <span - ref='message_span' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} + <div className='post__body'> + <div + key={`${post.id}_message`} + id={`${post.id}_message`} + className={postClass} + > + {loading} + <span + ref='message_span' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} + /> + </div> + <PostBodyAdditionalContent + post={this.state.post} + provider={this.state.provider} /> + {fileAttachmentHolder} + {this.embed} </div> - <PostBodyAdditionalContent - post={post} - /> - {fileAttachmentHolder} - {embed} </div> ); } diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx index 8189ba2d3..7e6f3f037 100644 --- a/web/react/components/post_body_additional_content.jsx +++ b/web/react/components/post_body_additional_content.jsx @@ -1,13 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const PostAttachmentList = require('./post_attachment_list.jsx'); +import PostAttachmentList from './post_attachment_list.jsx'; +import PostAttachmentOEmbed from './post_attachment_oembed.jsx'; export default class PostBodyAdditionalContent extends React.Component { constructor(props) { super(props); this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getOembedAttachment = this.getOembedAttachment.bind(this); this.getComponent = this.getComponent.bind(this); } @@ -25,17 +27,32 @@ export default class PostBodyAdditionalContent extends React.Component { ); } + getOembedAttachment() { + const link = this.props.post.props && this.props.post.props.oEmbedLink || ''; + return ( + <PostAttachmentOEmbed + key={'post_body_additional_content' + this.props.post.id} + provider={this.props.provider} + link={link} + /> + ); + } + getComponent() { - switch (this.state.type) { + switch (this.props.post.type) { case 'slack_attachment': return this.getSlackAttachment(); + case 'oEmbed': + return this.getOembedAttachment(); + default: + return ''; } } render() { let content = []; - if (this.state.shouldRender) { + if (Boolean(this.props.post.type)) { const component = this.getComponent(); if (component) { @@ -52,5 +69,6 @@ export default class PostBodyAdditionalContent extends React.Component { } PostBodyAdditionalContent.propTypes = { - post: React.PropTypes.object.isRequired -};
\ No newline at end of file + post: React.PropTypes.object.isRequired, + provider: React.PropTypes.object +}; diff --git a/web/react/components/post_deleted_modal.jsx b/web/react/components/post_deleted_modal.jsx index ba07a22f5..3723bcaba 100644 --- a/web/react/components/post_deleted_modal.jsx +++ b/web/react/components/post_deleted_modal.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); +import UserStore from '../stores/user_store.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; export default class PostDeletedModal extends React.Component { diff --git a/web/react/components/post_focus_view.jsx b/web/react/components/post_focus_view.jsx new file mode 100644 index 000000000..adcd78839 --- /dev/null +++ b/web/react/components/post_focus_view.jsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostsView from './posts_view.jsx'; + +import PostStore from '../stores/post_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; + +export default class PostFocusView extends React.Component { + constructor(props) { + super(props); + + this.onChannelChange = this.onChannelChange.bind(this); + this.onPostsChange = this.onPostsChange.bind(this); + this.handlePostsViewScroll = this.handlePostsViewScroll.bind(this); + this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); + + const focusedPostId = PostStore.getFocusedPostId(); + + this.state = { + scrollType: PostsView.SCROLL_TYPE_POST, + scrollPostId: focusedPostId, + postList: PostStore.getVisiblePosts(focusedPostId), + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }; + } + + componentDidMount() { + ChannelStore.addChangeListener(this.onChannelChange); + PostStore.addChangeListener(this.onPostsChange); + } + + componentWillUnmount() { + ChannelStore.removeChangeListener(this.onChannelChange); + PostStore.removeChangeListener(this.onPostsChange); + } + + onChannelChange() { + this.setState({ + scrollType: PostsView.SCROLL_TYPE_POST + }); + } + + onPostsChange() { + const focusedPostId = PostStore.getFocusedPostId(); + if (focusedPostId == null) { + return; + } + + this.setState({ + scrollPostId: focusedPostId, + postList: PostStore.getVisiblePosts(focusedPostId), + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }); + } + + handlePostsViewScroll() { + this.setState({scrollType: PostsView.SCROLL_TYPE_FREE}); + } + + loadMorePostsTop() { + EventHelpers.emitLoadMorePostsFocusedTopEvent(); + } + + loadMorePostsBottom() { + EventHelpers.emitLoadMorePostsFocusedBottomEvent(); + } + + getIntroMessage() { + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'>{'Beginning of Channel Archives'}</h4> + </div> + ); + } + + render() { + const postsToHighlight = {}; + postsToHighlight[this.state.scrollPostId] = true; + + return ( + <div id='post-list'> + <PostsView + key={'postfocusview'} + isActive={true} + postList={this.state.postList} + scrollType={this.state.scrollType} + scrollPostId={this.state.scrollPostId} + postViewScrolled={this.handlePostsViewScroll} + loadMorePostsTopClicked={this.loadMorePostsTop} + loadMorePostsBottomClicked={this.loadMorePostsBottom} + showMoreMessagesTop={!this.state.atTop} + showMoreMessagesBottom={!this.state.atBottom} + introText={this.getIntroMessage()} + messageSeparatorTime={0} + postsToHighlight={postsToHighlight} + /> + </div> + ); + } +} +PostFocusView.defaultProps = { +}; + +PostFocusView.propTypes = { +}; diff --git a/web/react/components/post_header.jsx b/web/react/components/post_header.jsx index 45e60c767..f18024343 100644 --- a/web/react/components/post_header.jsx +++ b/web/react/components/post_header.jsx @@ -1,8 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserProfile = require('./user_profile.jsx'); -var PostInfo = require('./post_info.jsx'); +import UserProfile from './user_profile.jsx'; +import PostInfo from './post_info.jsx'; +import * as Utils from '../utils/utils.jsx'; + +import Constants from '../utils/constants.jsx'; export default class PostHeader extends React.Component { constructor(props) { @@ -26,14 +29,23 @@ export default class PostHeader extends React.Component { ); } - botIndicator = <li className='post-header-col post-header__name bot-indicator'>{'BOT'}</li>; + botIndicator = <li className='col col__name bot-indicator'>{'BOT'}</li>; + } else if (Utils.isSystemMessage(post)) { + userProfile = ( + <UserProfile + userId={''} + overwriteName={Constants.SYSTEM_MESSAGE_PROFILE_NAME} + overwriteImage={Constants.SYSTEM_MESSAGE_PROFILE_IMAGE} + disablePopover={true} + /> + ); } return ( - <ul className='post-header post-header-post'> - <li className='post-header-col post-header__name'><strong>{userProfile}</strong></li> + <ul className='post__header'> + <li className='col col__name'>{userProfile}</li> {botIndicator} - <li className='post-info--hidden'> + <li className='col'> <PostInfo post={post} commentCount={this.props.commentCount} diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index a01d842e5..21683bb01 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -1,21 +1,31 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../stores/user_store.jsx'); -var utils = require('../utils/utils.jsx'); -var TimeSince = require('./time_since.jsx'); +import UserStore from '../stores/user_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import * as Utils from '../utils/utils.jsx'; +import TimeSince from './time_since.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; -var Constants = require('../utils/constants.jsx'); +import Constants from '../utils/constants.jsx'; + +const Overlay = ReactBootstrap.Overlay; +const Popover = ReactBootstrap.Popover; export default class PostInfo extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + copiedLink: false, + show: false + }; + + this.handlePermalinkCopy = this.handlePermalinkCopy.bind(this); } createDropdown() { var post = this.props.post; var isOwner = UserStore.getCurrentId() === post.user_id; - var isAdmin = utils.isAdmin(UserStore.getCurrentUser().roles); + var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles); if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) { return ''; @@ -32,30 +42,37 @@ export default class PostInfo extends React.Component { dataComments = this.props.commentCount; } - if (isOwner) { + if (this.props.allowReply === 'true') { dropdownContents.push( <li - key='editPost' + key='replyLink' role='presentation' > <a + className='link__reply theme' href='#' - role='menuitem' - data-toggle='modal' - data-target='#edit_post' - data-refocusid='#post_textbox' - data-title={type} - data-message={post.message} - data-postid={post.id} - data-channelid={post.channel_id} - data-comments={dataComments} + onClick={this.props.handleCommentClick} > - Edit + {'Reply'} </a> </li> ); } + dropdownContents.push( + <li + key='copyLink' + role='presentation' + > + <a + href='#' + onClick={(e) => this.setState({target: e.target, show: !this.state.show})} + > + {'Permalink'} + </a> + </li> + ); + if (isOwner || isAdmin) { dropdownContents.push( <li @@ -65,31 +82,33 @@ export default class PostInfo extends React.Component { <a href='#' role='menuitem' - data-toggle='modal' - data-target='#delete_post' - data-title={type} - data-postid={post.id} - data-channelid={post.channel_id} - data-comments={dataComments} + onClick={() => EventHelpers.showDeletePostModal(post, dataComments)} > - Delete + {'Delete'} </a> </li> ); } - if (this.props.allowReply === 'true') { + if (isOwner) { dropdownContents.push( <li - key='replyLink' + key='editPost' role='presentation' > <a - className='reply-link theme' href='#' - onClick={this.props.handleCommentClick} + role='menuitem' + data-toggle='modal' + data-target='#edit_post' + data-refocusid='#post_textbox' + data-title={type} + data-message={post.message} + data-postid={post.id} + data-channelid={post.channel_id} + data-comments={dataComments} > - Reply + {'Edit'} </a> </li> ); @@ -103,7 +122,7 @@ export default class PostInfo extends React.Component { <div> <a href='#' - className='dropdown-toggle theme' + className='dropdown-toggle post__dropdown theme' type='button' data-toggle='dropdown' aria-expanded='false' @@ -117,44 +136,111 @@ export default class PostInfo extends React.Component { </div> ); } + + handlePermalinkCopy() { + const textBox = $(ReactDOM.findDOMNode(this.refs.permalinkbox)); + textBox.select(); + + try { + const successful = document.execCommand('copy'); + if (successful) { + this.setState({copiedLink: true, show: false}); + } else { + this.setState({copiedLink: false}); + } + } catch (err) { + this.setState({copiedLink: false}); + } + } render() { var post = this.props.post; var comments = ''; - var lastCommentClass = ' comment-icon__container__hide'; - if (this.props.isLastComment) { - lastCommentClass = ' comment-icon__container__show'; + var showCommentClass = ''; + var commentCountText = this.props.commentCount; + + if (this.props.commentCount >= 1) { + showCommentClass = ' icon--show'; + } else { + commentCountText = ''; } - if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { + if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { comments = ( <a href='#' - className={'comment-icon__container theme' + lastCommentClass} + className={'comment-icon__container' + showCommentClass} onClick={this.props.handleCommentClick} > <span className='comment-icon' dangerouslySetInnerHTML={{__html: Constants.COMMENT_ICON}} /> - {this.props.commentCount} + {commentCountText} </a> ); } var dropdown = this.createDropdown(); + const permalink = TeamStore.getCurrentTeamUrl() + '/pl/' + post.id; + const copyButtonText = this.state.copiedLink ? (<div>{'Copy '}<i className='fa fa-check'/></div>) : 'Copy'; + const permalinkOverlay = ( + <Popover + id='permalink-overlay' + className='permalink-popover' + placement='left' + title='' + > + <div className='form-inline'> + <input + type='text' + readOnly='true' + ref='permalinkbox' + className='permalink-text form-control no-resize' + rows='1' + value={permalink} + /> + <button + data-copy-btn='true' + type='button' + className='btn btn-primary' + onClick={this.handlePermalinkCopy} + data-clipboard-text={permalink} + > + {copyButtonText} + </button> + </div> + </Popover> + ); + + const containerPadding = 20; + return ( - <ul className='post-header post-info'> - <li className='post-header-col'> + <ul className='post__header post__header--info'> + <li className='col'> <TimeSince eventTime={post.create_at} /> </li> - <li className='post-header-col post-header__reply'> - <div className='dropdown'> + <li className='col col__reply'> + {comments} + <div + className='dropdown' + ref='dotMenu' + > {dropdown} </div> - {comments} + <Overlay + show={this.state.show} + target={() => ReactDOM.findDOMNode(this.refs.dotMenu)} + onHide={() => this.setState({show: false})} + placement='left' + container={this} + containerPadding={containerPadding} + rootClose={true} + > + {permalinkOverlay} + </Overlay> </li> </ul> ); diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx index b782268fa..b7ac92672 100644 --- a/web/react/components/posts_view.jsx +++ b/web/react/components/posts_view.jsx @@ -1,18 +1,23 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); -const Post = require('./post.jsx'); -const Constants = require('../utils/constants.jsx'); +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import * as Utils from '../utils/utils.jsx'; +import Post from './post.jsx'; +import Constants from '../utils/constants.jsx'; +const Preferences = Constants.Preferences; export default class PostsView extends React.Component { constructor(props) { super(props); + this.updateState = this.updateState.bind(this); this.handleScroll = this.handleScroll.bind(this); this.isAtBottom = this.isAtBottom.bind(this); this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); this.createPosts = this.createPosts.bind(this); this.updateScrolling = this.updateScrolling.bind(this); this.handleResize = this.handleResize.bind(this); @@ -20,6 +25,8 @@ export default class PostsView extends React.Component { this.jumpToPostNode = null; this.wasAtBottom = true; this.scrollHeight = 0; + + this.state = {displayNameType: PreferenceStore.getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value}; } static get SCROLL_TYPE_FREE() { return 1; @@ -27,12 +34,18 @@ export default class PostsView extends React.Component { static get SCROLL_TYPE_BOTTOM() { return 2; } - static get SIDEBAR_OPEN() { + static get SCROLL_TYPE_SIDEBAR_OPEN() { return 3; } static get SCROLL_TYPE_NEW_MESSAGE() { return 4; } + static get SCROLL_TYPE_POST() { + return 5; + } + updateState() { + this.setState({displayNameType: PreferenceStore.getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value}); + } isAtBottom() { return ((this.refs.postlist.scrollHeight - this.refs.postlist.scrollTop) === this.refs.postlist.clientHeight); } @@ -47,15 +60,22 @@ export default class PostsView extends React.Component { } } this.wasAtBottom = this.isAtBottom(); + if (!this.jumpToPostNode && childNodes.length > 0) { + this.jumpToPostNode = childNodes[childNodes.length - 1]; + } // --- -------- this.props.postViewScrolled(this.isAtBottom()); this.prevScrollHeight = this.refs.postlist.scrollHeight; + this.prevOffsetTop = this.jumpToPostNode.offsetTop; } loadMorePostsTop() { this.props.loadMorePostsTopClicked(); } + loadMorePostsBottom() { + this.props.loadMorePostsBottomClicked(); + } createPosts(posts, order) { const postCtls = []; let previousPostDay = new Date(0); @@ -63,15 +83,11 @@ export default class PostsView extends React.Component { let renderedLastViewed = false; - let numToDisplay = this.props.numPostsToDisplay; - if (order.length - 1 < numToDisplay) { - numToDisplay = order.length - 1; - } - - for (let i = numToDisplay; i >= 0; i--) { + for (let i = order.length - 1; i >= 0; i--) { const post = posts[order[i]]; const parentPost = posts[post.parent_id]; const prevPost = posts[order[i + 1]]; + const postUserId = Utils.isSystemMessage(post) ? '' : post.user_id; // If the post is a comment whose parent has been deleted, don't add it to the list. if (parentPost && parentPost.state === Constants.POST_DELETED) { @@ -83,32 +99,73 @@ export default class PostsView extends React.Component { let hideProfilePic = false; if (prevPost) { - sameUser = prevPost.user_id === post.user_id && post.create_at - prevPost.create_at <= 1000 * 60 * 5; + const postIsComment = Utils.isComment(post); + const prevPostIsComment = Utils.isComment(prevPost); + const postFromWebhook = Boolean(post.props && post.props.from_webhook); + const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook); + const prevPostUserId = Utils.isSystemMessage(prevPost) ? '' : prevPost.user_id; + let prevWebhookName = ''; + if (prevPost.props && prevPost.props.override_username) { + prevWebhookName = prevPost.props.override_username; + } + let curWebhookName = ''; + if (post.props && post.props.override_username) { + curWebhookName = post.props.override_username; + } - sameRoot = Utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id); + // consider posts from the same user if: + // the previous post was made by the same user as the current post, + // the previous post was made within 5 minutes of the current post, + // the previous post and current post are both from webhooks or both not, + // the previous post and current post have the same webhook usernames + if (prevPostUserId === postUserId && + post.create_at - prevPost.create_at <= 1000 * 60 * 5 && + postFromWebhook === prevPostFromWebhook && + prevWebhookName === curWebhookName) { + sameUser = true; + } + + // consider posts from the same root if: + // the current post is a comment, + // the current post has the same root as the previous post + if (postIsComment && (prevPost.id === post.root_id || prevPost.root_id === post.root_id)) { + sameRoot = true; + } + + // consider posts from the same root if: + // the current post is not a comment, + // the previous post is not a comment, + // the previous post is from the same user + if (!postIsComment && !prevPostIsComment && sameUser) { + sameRoot = true; + } // hide the profile pic if: // the previous post was made by the same user as the current post, // the previous post is not a comment, // the current post is not a comment, - // the current post is not from a webhook - // and the previous post is not from a webhook - if ((prevPost.user_id === post.user_id) && - !Utils.isComment(prevPost) && - !Utils.isComment(post) && - (!post.props || !post.props.from_webhook) && - (!prevPost.props || !prevPost.props.from_webhook)) { + // the previous post and current post are both from webhooks or both not, + // the previous post and current post have the same webhook usernames + if (prevPostUserId === postUserId && + !prevPostIsComment && + !postIsComment && + postFromWebhook === prevPostFromWebhook && + prevWebhookName === curWebhookName) { hideProfilePic = true; } } // check if it's the last comment in a consecutive string of comments on the same post // it is the last comment if it is last post in the channel or the next post has a different root post - var isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); + const isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); + + const keyPrefix = post.id ? post.id : i; + + const shouldHighlight = this.props.postsToHighlight && this.props.postsToHighlight.hasOwnProperty(post.id); - var postCtl = ( + const postCtl = ( <Post - key={post.id + 'postKey'} + key={keyPrefix + 'postKey'} ref={post.id} sameUser={sameUser} sameRoot={sameRoot} @@ -117,6 +174,9 @@ export default class PostsView extends React.Component { posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} + shouldHighlight={shouldHighlight} + onClick={() => EventHelpers.emitPostFocusEvent(post.id)} //eslint-disable-line no-loop-func + displayNameType={this.state.displayNameType} /> ); @@ -133,7 +193,7 @@ export default class PostsView extends React.Component { ); } - if (post.user_id !== userId && + if (postUserId !== userId && this.props.messageSeparatorTime !== 0 && post.create_at > this.props.messageSeparatorTime && !renderedLastViewed) { @@ -178,9 +238,12 @@ export default class PostsView extends React.Component { this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; } }); - } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPost) { + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPostId) { window.requestAnimationFrame(() => { - const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPost]); + const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]); + if (postNode == null) { + return; + } postNode.scrollIntoView(); if (this.refs.postlist.scrollTop === postNode.offsetTop) { this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3); @@ -188,7 +251,7 @@ export default class PostsView extends React.Component { this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - postNode.offsetTop); } }); - } else if (this.props.scrollType === PostsView.SIDEBAR_OPEN) { + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_SIDEBAR_OPEN) { // If we are at the bottom then stay there if (this.wasAtBottom) { this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; @@ -204,7 +267,10 @@ export default class PostsView extends React.Component { } } else if (this.refs.postlist.scrollHeight !== this.prevScrollHeight) { window.requestAnimationFrame(() => { - this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + // Only need to jump if we added posts to the top. + if (this.jumpToPostNode && (this.jumpToPostNode.offsetTop !== this.prevOffsetTop)) { + this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + } }); } } @@ -212,35 +278,47 @@ export default class PostsView extends React.Component { this.updateScrolling(); } componentDidMount() { - this.updateScrolling(); + if (this.props.postList != null) { + this.updateScrolling(); + } window.addEventListener('resize', this.handleResize); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); } componentDidUpdate() { - this.updateScrolling(); + if (this.props.postList != null) { + this.updateScrolling(); + } + } + componentWillReceiveProps(nextProps) { + if (!this.props.isActive && nextProps.isActive) { + this.updateState(); + PreferenceStore.addChangeListener(this.updateState); + } else if (this.props.isActive && !nextProps.isActive) { + PreferenceStore.removeChangeListener(this.updateState); + } } - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps, nextState) { if (this.props.isActive !== nextProps.isActive) { return true; } if (this.props.postList !== nextProps.postList) { return true; } - if (this.props.scrollPost !== nextProps.scrollPost) { + if (this.props.scrollPostId !== nextProps.scrollPostId) { return true; } if (this.props.scrollType !== nextProps.scrollType && nextProps.scrollType !== PostsView.SCROLL_TYPE_FREE) { return true; } - if (this.props.numPostsToDisplay !== nextProps.numPostsToDisplay) { + if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { return true; } - if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { + if (!Utils.areObjectsEqual(this.props.postList, nextProps.postList)) { return true; } - if (!Utils.areStatesEqual(this.props.postList, nextProps.postList)) { + if (nextState.displayNameType !== this.state.displayNameType) { return true; } @@ -249,7 +327,8 @@ export default class PostsView extends React.Component { render() { let posts = []; let order = []; - let moreMessages; + let moreMessagesTop; + let moreMessagesBottom; let postElements; let activeClass = 'inactive'; if (this.props.postList != null) { @@ -257,10 +336,10 @@ export default class PostsView extends React.Component { order = this.props.postList.order; // Create intro message or top loadmore link - if (order.length >= this.props.numPostsToDisplay) { - moreMessages = ( + if (this.props.showMoreMessagesTop) { + moreMessagesTop = ( <a - ref='loadmore' + ref='loadmoretop' className='more-messages-text theme' href='#' onClick={this.loadMorePostsTop} @@ -269,7 +348,23 @@ export default class PostsView extends React.Component { </a> ); } else { - moreMessages = this.props.introText; + moreMessagesTop = this.props.introText; + } + + // Give option to load more posts at bottom if nessisary + if (this.props.showMoreMessagesBottom) { + moreMessagesBottom = ( + <a + ref='loadmorebottom' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsBottom} + > + {'Load more messages'} + </a> + ); + } else { + moreMessagesBottom = null; } // Create post elements @@ -292,8 +387,9 @@ export default class PostsView extends React.Component { ref='postlistcontent' className='post-list__content' > - {moreMessages} + {moreMessagesTop} {postElements} + {moreMessagesBottom} </div> </div> </div> @@ -306,11 +402,14 @@ PostsView.defaultProps = { PostsView.propTypes = { isActive: React.PropTypes.bool, postList: React.PropTypes.object, - scrollPost: React.PropTypes.string, + scrollPostId: React.PropTypes.string, scrollType: React.PropTypes.number, postViewScrolled: React.PropTypes.func.isRequired, loadMorePostsTopClicked: React.PropTypes.func.isRequired, - numPostsToDisplay: React.PropTypes.number, + loadMorePostsBottomClicked: React.PropTypes.func.isRequired, + showMoreMessagesTop: React.PropTypes.bool, + showMoreMessagesBottom: React.PropTypes.bool, introText: React.PropTypes.element, - messageSeparatorTime: React.PropTypes.number + messageSeparatorTime: React.PropTypes.number, + postsToHighlight: React.PropTypes.object }; diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx index 8b92a26a7..631bd1872 100644 --- a/web/react/components/posts_view_container.jsx +++ b/web/react/components/posts_view_container.jsx @@ -1,22 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const PostsView = require('./posts_view.jsx'); -const LoadingScreen = require('./loading_screen.jsx'); -const ChannelInviteModal = require('./channel_invite_modal.jsx'); +import PostsView from './posts_view.jsx'; +import LoadingScreen from './loading_screen.jsx'; -const ChannelStore = require('../stores/channel_store.jsx'); -const PostStore = require('../stores/post_store.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; -const Constants = require('../utils/constants.jsx'); -const ActionTypes = Constants.ActionTypes; +import Constants from '../utils/constants.jsx'; -import {createChannelIntroMessage} from '../utils/channel_intro_mssages.jsx'; +import {createChannelIntroMessage} from '../utils/channel_intro_messages.jsx'; export default class PostsViewContainer extends React.Component { constructor() { @@ -27,27 +23,26 @@ export default class PostsViewContainer extends React.Component { this.onPostsChange = this.onPostsChange.bind(this); this.handlePostsViewScroll = this.handlePostsViewScroll.bind(this); this.loadMorePostsTop = this.loadMorePostsTop.bind(this); - this.postsLoaded = this.postsLoaded.bind(this); - this.postsLoadedFailure = this.postsLoadedFailure.bind(this); this.handlePostsViewJumpRequest = this.handlePostsViewJumpRequest.bind(this); const currentChannelId = ChannelStore.getCurrentId(); const state = { scrollType: PostsView.SCROLL_TYPE_BOTTOM, - scrollPost: null, - numPostsToDisplay: Constants.POST_CHUNK_SIZE + scrollPost: null }; if (currentChannelId) { Object.assign(state, { currentChannelIndex: 0, channels: [currentChannelId], - postLists: [this.getChannelPosts(currentChannelId)] + postLists: [this.getChannelPosts(currentChannelId)], + atTop: [PostStore.getVisibilityAtTop(currentChannelId)] }); } else { Object.assign(state, { currentChannelIndex: null, channels: [], - postLists: [] + postLists: [], + atTop: [] }); } @@ -78,24 +73,21 @@ export default class PostsViewContainer extends React.Component { }); break; case Constants.PostsViewJumpTypes.SIDEBAR_OPEN: - this.setState({scrollType: PostsView.SIDEBAR_OPEN}); + this.setState({scrollType: PostsView.SCROLL_TYPE_SIDEBAR_OPEN}); break; } } onChannelChange() { const postLists = this.state.postLists.slice(); + const atTop = this.state.atTop.slice(); const channels = this.state.channels.slice(); const channelId = ChannelStore.getCurrentId(); // Has the channel really changed? if (channelId === channels[this.state.currentChannelIndex]) { - // Dirty hack - this.forceUpdate(); return; } - PostStore.clearUnseenDeletedPosts(channelId); - let lastViewed = Number.MAX_VALUE; const member = ChannelStore.getMember(channelId); if (member != null) { @@ -106,116 +98,48 @@ export default class PostsViewContainer extends React.Component { if (newIndex === -1) { newIndex = channels.length; channels.push(channelId); - postLists[newIndex] = this.getChannelPosts(channelId); + atTop[newIndex] = PostStore.getVisibilityAtTop(channelId); } + + // make sure we have the latest posts from the store + postLists[newIndex] = this.getChannelPosts(channelId); + this.setState({ currentChannelIndex: newIndex, currentLastViewed: lastViewed, scrollType: PostsView.SCROLL_TYPE_NEW_MESSAGE, channels, - postLists}); + postLists, + atTop}); } onChannelLeave(id) { const postLists = this.state.postLists.slice(); const channels = this.state.channels.slice(); + const atTop = this.state.atTop.slice(); const index = channels.indexOf(id); if (index !== -1) { postLists.splice(index, 1); channels.splice(index, 1); + atTop.splice(index, 1); } - this.setState({channels, postLists}); + this.setState({channels, postLists, atTop}); } onPostsChange() { const channels = this.state.channels; const postLists = this.state.postLists.slice(); - const newPostsView = this.getChannelPosts(channels[this.state.currentChannelIndex]); + const atTop = this.state.atTop.slice(); + const currentChannelId = channels[this.state.currentChannelIndex]; + const newPostsView = this.getChannelPosts(currentChannelId); postLists[this.state.currentChannelIndex] = newPostsView; - this.setState({postLists}); + atTop[this.state.currentChannelIndex] = PostStore.getVisibilityAtTop(currentChannelId); + this.setState({postLists, atTop}); } getChannelPosts(id) { - const postList = PostStore.getPosts(id); - - if (postList != null) { - const deletedPosts = PostStore.getUnseenDeletedPosts(id); - - if (deletedPosts && Object.keys(deletedPosts).length > 0) { - for (const pid in deletedPosts) { - if (deletedPosts.hasOwnProperty(pid)) { - postList.posts[pid] = deletedPosts[pid]; - postList.order.unshift(pid); - } - } - - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { - return -1; - } - if (postList.posts[a].create_at < postList.posts[b].create_at) { - return 1; - } - return 0; - }); - } - - const pendingPostList = PostStore.getPendingPosts(id); - - if (pendingPostList) { - postList.order = pendingPostList.order.concat(postList.order); - for (const ppid in pendingPostList.posts) { - if (pendingPostList.posts.hasOwnProperty(ppid)) { - postList.posts[ppid] = pendingPostList.posts[ppid]; - } - } - } - } - - return postList; + return PostStore.getVisiblePosts(id); } loadMorePostsTop() { - const postLists = this.state.postLists; - const channels = this.state.channels; - const currentChannelId = channels[this.state.currentChannelIndex]; - const currentPostList = postLists[this.state.currentChannelIndex]; - - this.setState({numPostsToDisplay: this.state.numPostsToDisplay + Constants.POST_CHUNK_SIZE}); - - Client.getPostsPage( - currentChannelId, - currentPostList.order.length, - Constants.POST_CHUNK_SIZE, - this.postsLoaded, - this.postsLoadedFailure - ); - } - postsLoaded(data) { - if (!data) { - return; - } - - if (data.order.length === 0) { - return; - } - - const postLists = this.state.postLists; - const currentPostList = postLists[this.state.currentChannelIndex]; - const channels = this.state.channels; - const currentChannelId = channels[this.state.currentChannelIndex]; - - var newPostList = {}; - newPostList.posts = Object.assign(currentPostList.posts, data.posts); - newPostList.order = currentPostList.order.concat(data.order); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POSTS, - id: currentChannelId, - post_list: newPostList - }); - - Client.getProfiles(); - } - postsLoadedFailure(err) { - AsyncClient.dispatchError(err, 'getPosts'); + EventHelpers.emitLoadMorePostsEvent(); } handlePostsViewScroll(atBottom) { if (atBottom) { @@ -225,7 +149,7 @@ export default class PostsViewContainer extends React.Component { } } shouldComponentUpdate(nextProps, nextState) { - if (Utils.areStatesEqual(this.state, nextState)) { + if (Utils.areObjectsEqual(this.state, nextState)) { return false; } @@ -246,15 +170,17 @@ export default class PostsViewContainer extends React.Component { isActive={isActive} postList={postLists[i]} scrollType={this.state.scrollType} - scrollPost={this.state.scrollPost} + scrollPostId={this.state.scrollPost} postViewScrolled={this.handlePostsViewScroll} loadMorePostsTopClicked={this.loadMorePostsTop} - numPostsToDisplay={this.state.numPostsToDisplay} - introText={channel ? createChannelIntroMessage(channel, () => this.setState({showInviteModal: true})) : null} + loadMorePostsBottomClicked={() => {}} + showMoreMessagesTop={!this.state.atTop[this.state.currentChannelIndex]} + showMoreMessagesBottom={false} + introText={channel ? createChannelIntroMessage(channel) : null} messageSeparatorTime={this.state.currentLastViewed} /> ); - if ((!postLists[i] || !channel) && isActive) { + if (!postLists[i] && isActive) { postListCtls.push( <LoadingScreen position='absolute' @@ -267,10 +193,6 @@ export default class PostsViewContainer extends React.Component { return ( <div id='post-list'> {postListCtls} - <ChannelInviteModal - show={this.state.showInviteModal} - onModalDismissed={() => this.setState({showInviteModal: false})} - /> </div> ); } diff --git a/web/react/components/providers.json b/web/react/components/providers.json new file mode 100644 index 000000000..b5899c225 --- /dev/null +++ b/web/react/components/providers.json @@ -0,0 +1,376 @@ +[ + { + "patterns": [ + "http://(?:www\\.)?xkcd\\.com/\\d+/?" + ], + "name": "XKCD", + "height": 110 + }, + { + "patterns": [ + "https?://soundcloud.com/.*/.*" + ], + "name": "SoundCloud", + "height": 140 + }, + { + "patterns": [ + "https?://(?:www\\.)?flickr\\.com/.*", + "https?://flic\\.kr/p/[a-zA-Z0-9]+" + ], + "name": "Flickr", + "height": 110 + }, + { + "patterns": [ + "http://www\\.ted\\.com/talks/.+\\.html" + ], + "name": "TED", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?theverge\\.com/\\d{4}/\\d{1,2}/\\d{1,2}/\\d+/[^/]+/?$" + ], + "name": "The Verge", + "height": 110 + }, + { + "patterns": [ + "http://.*\\.viddler\\.com/.*" + ], + "name": "Viddler", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?avclub\\.com/article/[^/]+/?$" + ], + "name": "The AV Club", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?wired\\.com/([^/]+/)?\\d+/\\d+/[^/]+/?$" + ], + "name": "Wired", + "height": 110 + }, + { + "patterns": [ + "http://www\\.theonion\\.com/articles/[^/]+/?" + ], + "name": "The Onion", + "height": 110 + }, + { + "patterns": [ + "http://yfrog\\.com/[0-9a-zA-Z]+/?$" + ], + "name": "YFrog", + "height": 110 + }, + { + "patterns": [ + "http://www\\.duffelblog\\.com/\\d{4}/\\d{1,2}/[^/]+/?$" + ], + "name": "The Duffel Blog", + "height": 110 + }, + { + "patterns": [ + "http://www\\.clickhole\\.com/article/[^/]+/?" + ], + "name": "Clickhole", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www.)?skitch.com/([^/]+)/[^/]+/.+", + "http://skit.ch/[^/]+" + ], + "name": "Skitch", + "height": 110 + }, + { + "patterns": [ + "https?://(alpha|posts|photos)\\.app\\.net/.*" + ], + "name": "ADN", + "height": 110 + }, + { + "patterns": [ + "https?://gist\\.github\\.com/(?:[-0-9a-zA-Z]+/)?([0-9a-fA-f]+)" + ], + "name": "Gist", + "height": 110 + }, + { + "patterns": [ + "https?://www\\.(dropbox\\.com/s/.+\\.(?:jpg|png|gif))", + "https?://db\\.tt/[a-zA-Z0-9]+" + ], + "name": "Dropbox", + "height": 110 + }, + { + "patterns": [ + "https?://[^\\.]+\\.wikipedia\\.org/wiki/(?!Talk:)[^#]+(?:#(.+))?" + ], + "name": "Wikipedia", + "height": 110 + }, + { + "patterns": [ + "http://www.traileraddict.com/trailer/[^/]+/trailer" + ], + "name": "TrailerAddict", + "height": 110 + }, + { + "patterns": [ + "http://lockerz\\.com/[sd]/\\d+" + ], + "name": "Lockerz", + "height": 110 + }, + { + "patterns": [ + "http://gifuk\\.com/s/[0-9a-f]{16}" + ], + "name": "GIFUK", + "height": 110 + }, + { + "patterns": [ + "http://trailers\\.apple\\.com/trailers/[^/]+/[^/]+" + ], + "name": "iTunes Movie Trailers", + "height": 110 + }, + { + "patterns": [ + "http://gfycat\\.com/([a-zA-Z]+)" + ], + "name": "Gfycat", + "height": 110 + }, + { + "patterns": [ + "http://bash\\.org/\\?(\\d+)" + ], + "name": "Bash.org", + "height": 110 + }, + { + "patterns": [ + "http://arstechnica\\.com/[^/]+/\\d+/\\d+/[^/]+/?$" + ], + "name": "Ars Technica", + "height": 110 + }, + { + "patterns": [ + "http://imgur\\.com/gallery/[0-9a-zA-Z]+" + ], + "name": "Imgur", + "height": 110 + }, + { + "patterns": [ + "http://www\\.asciiartfarts\\.com/[0-9]+\\.html" + ], + "name": "ASCII Art Farts", + "height": 110 + }, + { + "patterns": [ + "http://www\\.monoprice\\.com/products/product\\.asp\\?.*p_id=\\d+" + ], + "name": "Monoprice", + "height": 110 + }, + { + "patterns": [ + "http://boingboing\\.net/\\d{4}/\\d{2}/\\d{2}/[^/]+\\.html" + ], + "name": "Boing Boing", + "height": 110 + }, + { + "patterns": [ + "https?://github\\.com/([^/]+)/([^/]+)/commit/(.+)", + "http://git\\.io/[_0-9a-zA-Z]+" + ], + "name": "Github Commit", + "height": 110 + }, + { + "patterns": [ + "https?://open\\.spotify\\.com/(track|album)/([0-9a-zA-Z]{22})" + ], + "name": "Spotify", + "height": 110 + }, + { + "patterns": [ + "https?://path\\.com/p/([0-9a-zA-Z]+)$" + ], + "name": "Path", + "height": 110 + }, + { + "patterns": [ + "http://www.funnyordie.com/videos/[^/]+/.+" + ], + "name": "Funny or Die", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?twitpic\\.com/([^/]+)" + ], + "name": "Twitpic", + "height": 110 + }, + { + "patterns": [ + "https?://www\\.giantbomb\\.com/videos/[^/]+/\\d+-\\d+/?" + ], + "name": "GiantBomb", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?beeradvocate\\.com/beer/profile/\\d+/\\d+" + ], + "name": "Beer Advocate", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?imdb.com/title/(tt\\d+)" + ], + "name": "IMDB", + "height": 110 + }, + { + "patterns": [ + "http://cl\\.ly/(?:image/)?[0-9a-zA-Z]+/?$" + ], + "name": "CloudApp", + "height": 110 + }, + { + "patterns": [ + "http://clyp\\.it/.*" + ], + "name": "Clyp", + "height": 110 + }, + { + "patterns": [ + "http://www\\.hulu\\.com/watch/.*" + ], + "name": "Hulu", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www|mobile\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/?$", + "https?://t\\.co/[a-zA-Z0-9]+" + ], + "name": "Twitter", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?vimeo\\.com/.+" + ], + "name": "Vimeo", + "height": 110 + }, + { + "patterns": [ + "http://www\\.amazon\\.com/(?:.+/)?[gd]p/(?:product/)?(?:tags-on-product/)?([a-zA-Z0-9]+)", + "http://amzn\\.com/([^/]+)" + ], + "name": "Amazon", + "height": 110 + }, + { + "patterns": [ + "http://qik\\.com/video/.*" + ], + "name": "Qik", + "height": 110 + }, + { + "patterns": [ + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/?", + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/track/[^/]+/?", + "http://www\\.rdio\\.com/people/[^/]+/playlists/\\d+/[^/]+" + ], + "name": "Rdio", + "height": 110 + }, + { + "patterns": [ + "http://www\\.slideshare\\.net/.*/.*" + ], + "name": "SlideShare", + "height": 110 + }, + { + "patterns": [ + "http://imgur\\.com/([0-9a-zA-Z]+)$" + ], + "name": "Imgur", + "height": 110 + }, + { + "patterns": [ + "https?://instagr(?:\\.am|am\\.com)/p/.+" + ], + "name": "Instagram", + "height": 110 + }, + { + "patterns": [ + "http://www\\.twitlonger\\.com/show/[a-zA-Z0-9]+", + "http://tl\\.gd/[^/]+" + ], + "name": "Twitlonger", + "height": 110 + }, + { + "patterns": [ + "https?://vine.co/v/[a-zA-Z0-9]+" + ], + "name": "Vine", + "height": 490 + }, + { + "patterns": [ + "http://www\\.urbandictionary\\.com/define\\.php\\?term=.+" + ], + "name": "Urban Dictionary", + "height": 110 + }, + { + "patterns": [ + "http://picplz\\.com/user/[^/]+/pic/[^/]+" + ], + "name": "Picplz", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/photo/\\d+(?:/large|/)?$", + "https?://pic\\.twitter\\.com/.+" + ], + "name": "Twitter", + "height": 110 + } +] diff --git a/web/react/components/register_app_modal.jsx b/web/react/components/register_app_modal.jsx index c40409dcc..f49b33f73 100644 --- a/web/react/components/register_app_modal.jsx +++ b/web/react/components/register_app_modal.jsx @@ -1,22 +1,58 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); +import * as Client from '../utils/client.jsx'; +import ModalStore from '../stores/modal_store.jsx'; + +const Modal = ReactBootstrap.Modal; + +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; export default class RegisterAppModal extends React.Component { constructor() { super(); - this.register = this.register.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); this.onHide = this.onHide.bind(this); this.save = this.save.bind(this); + this.updateShow = this.updateShow.bind(this); - this.state = {clientId: '', clientSecret: '', saved: false}; + this.state = { + clientId: '', + clientSecret: '', + saved: false, + show: false + }; } componentDidMount() { - $(ReactDOM.findDOMNode(this)).on('hide.bs.modal', this.onHide); + ModalStore.addModalListener(ActionTypes.TOGGLE_REGISTER_APP_MODAL, this.updateShow); + } + componentWillUnmount() { + ModalStore.removeModalListener(ActionTypes.TOGGLE_REGISTER_APP_MODAL, this.updateShow); + } + updateShow(show) { + if (!show) { + if (this.state.clientId !== '' && !this.state.saved) { + return; + } + + this.setState({ + clientId: '', + clientSecret: '', + saved: false, + homepageError: null, + callbackError: null, + serverError: null, + nameError: null + }); + } + + this.setState({show}); } - register() { + handleSubmit(e) { + e.preventDefault(); + var state = this.state; state.serverError = null; @@ -94,6 +130,7 @@ export default class RegisterAppModal extends React.Component { } var body = ''; + var footer = ''; if (this.state.clientId === '') { body = ( <div className='settings-modal'> @@ -148,24 +185,29 @@ export default class RegisterAppModal extends React.Component { </div> </div> {serverError} - <hr /> - <a - className='btn btn-sm theme pull-right' - href='#' - data-dismiss='modal' - aria-label='Close' - > - {'Cancel'} - </a> - <a - className='btn btn-sm btn-primary pull-right' - onClick={this.register} - > - {'Register'} - </a> </div> </div> ); + + footer = ( + <div> + <button + type='button' + className='btn btn-default' + onClick={() => this.updateShow(false)} + > + {'Cancel'} + </button> + <button + onClick={this.handleSubmit} + type='submit' + className='btn btn-primary' + tabIndex='3' + > + {'Register'} + </button> + </div> + ); } else { var btnClass = ' disabled'; if (this.state.saved) { @@ -173,17 +215,35 @@ export default class RegisterAppModal extends React.Component { } body = ( - <div className='form-group user-settings'> - <h3>{'Your Application Credentials'}</h3> - <br/> - <br/> - <label className='col-sm-12 control-label'>{'Client ID: '}{this.state.clientId}</label> - <label className='col-sm-12 control-label'>{'Client Secret: '}{this.state.clientSecret}</label> + <div className='form-horizontal user-settings'> + <h4 className='padding-bottom x3'>{'Your Application Credentials'}</h4> <br/> + <div className='row'> + <label className='col-sm-4 control-label'>{'Client ID'}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + value={this.state.clientId} + readOnly='true' + /> + </div> + </div> <br/> + <div className='row padding-top x2'> + <label className='col-sm-4 control-label'>{'Client Secret'}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + value={this.state.clientSecret} + readOnly='true' + /> + </div> + </div> <br/> <br/> - <strong>{'Save these somewhere SAFE and SECURE. We can retrieve your Client Id if you lose it, but your Client Secret will be lost forever if you were to lose it.'}</strong> + <strong>{'Save these somewhere SAFE and SECURE. Treat your Client ID as your app\'s username and your Client Secret as the app\'s password.'}</strong> <br/> <br/> <div className='checkbox'> @@ -192,56 +252,50 @@ export default class RegisterAppModal extends React.Component { ref='save' type='checkbox' checked={this.state.saved} - onClick={this.save} - > - {'I have saved both my Client Id and Client Secret somewhere safe'} - </input> + onChange={this.save} + /> + {'I have saved both my Client Id and Client Secret somewhere safe'} </label> </div> - <a - className={'btn btn-sm btn-primary pull-right' + btnClass} - href='#' - data-dismiss='modal' - aria-label='Close' - > - {'Close'} - </a> </div> ); + + footer = ( + <a + className={'btn btn-sm btn-primary pull-right' + btnClass} + href='#' + onClick={(e) => { + e.preventDefault(); + this.updateShow(false); + }} + > + {'Close'} + </a> + ); } return ( - <div - className='modal fade' - ref='modal' - id='register_app' - role='dialog' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>{'×'}</span> - </button> - <h4 - className='modal-title' - ref='title' - > - {'Developer Applications'} - </h4> - </div> - <div className='modal-body'> - {body} - </div> - </div> - </div> - </div> + <span> + <Modal + show={this.state.show} + onHide={() => this.updateShow(false)} + > + <Modal.Header closeButton={true}> + <Modal.Title>{'Developer Applications'}</Modal.Title> + </Modal.Header> + <form + role='form' + className='form-horizontal' + > + <Modal.Body> + {body} + </Modal.Body> + <Modal.Footer> + {footer} + </Modal.Footer> + </form> + </Modal> + </span> ); } } diff --git a/web/react/components/removed_from_channel_modal.jsx b/web/react/components/removed_from_channel_modal.jsx index 7cf0a2ef1..69d038c22 100644 --- a/web/react/components/removed_from_channel_modal.jsx +++ b/web/react/components/removed_from_channel_modal.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var utils = require('../utils/utils.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; +import * as utils from '../utils/utils.jsx'; export default class RemovedFromChannelModal extends React.Component { constructor(props) { diff --git a/web/react/components/rename_channel_modal.jsx b/web/react/components/rename_channel_modal.jsx index 9fb3af035..c16216c68 100644 --- a/web/react/components/rename_channel_modal.jsx +++ b/web/react/components/rename_channel_modal.jsx @@ -1,10 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import Constants from '../utils/constants.jsx'; export default class RenameChannelModal extends React.Component { constructor(props) { @@ -36,10 +37,10 @@ export default class RenameChannelModal extends React.Component { return; } - let channel = ChannelStore.get(this.state.channelId); + const channel = ChannelStore.get(this.state.channelId); const oldName = channel.name; const oldDisplayName = channel.displayName; - let state = {serverError: ''}; + const state = {serverError: ''}; channel.display_name = this.state.displayName.trim(); if (!channel.display_name) { @@ -60,7 +61,7 @@ export default class RenameChannelModal extends React.Component { state.nameError = 'This field must be less than 22 characters'; state.invalid = true; } else { - let cleanedName = Utils.cleanUpUrlable(channel.name); + const cleanedName = Utils.cleanUpUrlable(channel.name); if (cleanedName === channel.name) { state.nameError = ''; } else { @@ -76,7 +77,7 @@ export default class RenameChannelModal extends React.Component { } Client.updateChannel(channel, - function handleUpdateSuccess() { + () => { $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); AsyncClient.getChannel(channel.id); @@ -84,12 +85,12 @@ export default class RenameChannelModal extends React.Component { ReactDOM.findDOMNode(this.refs.displayName).value = ''; ReactDOM.findDOMNode(this.refs.channelName).value = ''; - }.bind(this), - function handleUpdateError(err) { + }, + (err) => { state.serverError = err.message; state.invalid = true; this.setState(state); - }.bind(this) + } ); } onNameChange() { @@ -99,10 +100,12 @@ export default class RenameChannelModal extends React.Component { this.setState({displayName: ReactDOM.findDOMNode(this.refs.displayName).value}); } displayNameKeyUp() { - const displayName = ReactDOM.findDOMNode(this.refs.displayName).value.trim(); - const channelName = Utils.cleanUpUrlable(displayName); - ReactDOM.findDOMNode(this.refs.channelName).value = channelName; - this.setState({channelName: channelName}); + if (this.state.channelName !== Constants.DEFAULT_CHANNEL) { + const displayName = ReactDOM.findDOMNode(this.refs.displayName).value.trim(); + const channelName = Utils.cleanUpUrlable(displayName); + ReactDOM.findDOMNode(this.refs.channelName).value = channelName; + this.setState({channelName: channelName}); + } } handleClose() { this.setState({ @@ -150,6 +153,15 @@ export default class RenameChannelModal extends React.Component { serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; } + let handleInputLabel = 'Handle'; + let handleInputClass = 'form-control'; + let readOnlyHandleInput = false; + if (this.state.channelName === Constants.DEFAULT_CHANNEL) { + handleInputLabel += ' - Cannot be changed for the default channel'; + handleInputClass += ' disabled-input'; + readOnlyHandleInput = true; + } + return ( <div className='modal fade' @@ -167,15 +179,15 @@ export default class RenameChannelModal extends React.Component { className='close' data-dismiss='modal' > - <span aria-hidden='true'>×</span> - <span className='sr-only'>Close</span> + <span aria-hidden='true'>{'×'}</span> + <span className='sr-only'>{'Close'}</span> </button> - <h4 className='modal-title'>Rename Channel</h4> + <h4 className='modal-title'>{'Rename Channel'}</h4> </div> <form role='form'> <div className='modal-body'> <div className={displayNameClass}> - <label className='control-label'>Display Name</label> + <label className='control-label'>{'Display Name'}</label> <input onKeyUp={this.displayNameKeyUp} onChange={this.onDisplayNameChange} @@ -190,15 +202,16 @@ export default class RenameChannelModal extends React.Component { {displayNameError} </div> <div className={nameClass}> - <label className='control-label'>Handle</label> + <label className='control-label'>{handleInputLabel}</label> <input onChange={this.onNameChange} type='text' - className='form-control' + className={handleInputClass} ref='channelName' placeholder='lowercase alphanumeric's only' value={this.state.channelName} maxLength='64' + readOnly={readOnlyHandleInput} /> {nameError} </div> @@ -210,14 +223,14 @@ export default class RenameChannelModal extends React.Component { className='btn btn-default' data-dismiss='modal' > - Cancel + {'Cancel'} </button> <button onClick={this.handleSubmit} type='submit' className='btn btn-primary' > - Save + {'Save'} </button> </div> </form> diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index 8c6324c72..7aae5177e 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -1,19 +1,20 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostStore = require('../stores/post_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var UserProfile = require('./user_profile.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Utils = require('../utils/utils.jsx'); -var Constants = require('../utils/constants.jsx'); -var FileAttachmentList = require('./file_attachment_list.jsx'); -var Client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); +import PostStore from '../stores/post_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import UserProfile from './user_profile.jsx'; +import UserStore from '../stores/user_store.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; +import FileAttachmentList from './file_attachment_list.jsx'; +import * as Client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; var ActionTypes = Constants.ActionTypes; -var TextFormatting = require('../utils/text_formatting.jsx'); -var twemoji = require('twemoji'); +import * as TextFormatting from '../utils/text_formatting.jsx'; +import twemoji from 'twemoji'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; export default class RhsComment extends React.Component { constructor(props) { @@ -61,7 +62,7 @@ export default class RhsComment extends React.Component { this.parseEmojis(); } shouldComponentUpdate(nextProps) { - if (!Utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } @@ -114,12 +115,7 @@ export default class RhsComment extends React.Component { <a href='#' role='menuitem' - data-toggle='modal' - data-target='#delete_post' - data-title='Comment' - data-postid={post.id} - data-channelid={post.channel_id} - data-comments={0} + onClick={() => EventHelpers.showDeletePostModal(post, 0)} > {'Delete'} </a> @@ -135,7 +131,7 @@ export default class RhsComment extends React.Component { <div className='dropdown'> <a href='#' - className='dropdown-toggle theme' + className='post__dropdown dropdown-toggle' type='button' data-toggle='dropdown' aria-expanded='false' @@ -197,38 +193,39 @@ export default class RhsComment extends React.Component { return ( <div className={'post ' + currentUserCss}> - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} - height='36' - width='36' - /> - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'> - <strong><UserProfile userId={post.user_id} /></strong> - </li> - <li className='post-header-col'> - <time className='post-profile-time'> - {Utils.displayCommentDateTime(post.create_at)} - </time> - </li> - <li className='post-header-col post-header__reply'> - {dropdown} - </li> - </ul> - <div className='post-body'> - <div className={postClass}> - {loading} - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> + <div className='post__img'> + <img + src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} + height='36' + width='36' + /> + </div> + <div> + <ul className='post__header'> + <li className='col__name'> + <strong><UserProfile userId={post.user_id} /></strong> + </li> + <li className='col'> + <time className='post__time'> + {Utils.displayCommentDateTime(post.create_at)} + </time> + </li> + <li className='col col__reply'> + {dropdown} + </li> + </ul> + <div className='post__body'> + <div className={postClass}> + {loading} + <div + ref='message_holder' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> + </div> + {fileAttachment} </div> - {fileAttachment} </div> </div> </div> diff --git a/web/react/components/rhs_header_post.jsx b/web/react/components/rhs_header_post.jsx index 856eea91d..990b33eb5 100644 --- a/web/react/components/rhs_header_post.jsx +++ b/web/react/components/rhs_header_post.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const Constants = require('../utils/constants.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; export default class RhsHeaderPost extends React.Component { diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index e3b023841..dd9a793be 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -1,15 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); -var UserProfile = require('./user_profile.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var TextFormatting = require('../utils/text_formatting.jsx'); -var utils = require('../utils/utils.jsx'); -var FileAttachmentList = require('./file_attachment_list.jsx'); -var twemoji = require('twemoji'); -var Constants = require('../utils/constants.jsx'); -const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; +import UserProfile from './user_profile.jsx'; +import UserStore from '../stores/user_store.jsx'; +import * as TextFormatting from '../utils/text_formatting.jsx'; +import * as utils from '../utils/utils.jsx'; +import * as Emoji from '../utils/emoticons.jsx'; +import FileAttachmentList from './file_attachment_list.jsx'; +import twemoji from 'twemoji'; +import PostBodyAdditionalContent from './post_body_additional_content.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; + +import Constants from '../utils/constants.jsx'; export default class RhsRootPost extends React.Component { constructor(props) { @@ -20,13 +23,17 @@ export default class RhsRootPost extends React.Component { this.state = {}; } parseEmojis() { - twemoji.parse(ReactDOM.findDOMNode(this), {size: Constants.EMOJI_SIZE}); + twemoji.parse(ReactDOM.findDOMNode(this), { + className: 'emoji twemoji', + base: '', + folder: Emoji.getImagePathForEmoticon() + }); } componentDidMount() { this.parseEmojis(); } shouldComponentUpdate(nextProps) { - if (!utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } @@ -37,7 +44,9 @@ export default class RhsRootPost extends React.Component { } render() { var post = this.props.post; - var isOwner = UserStore.getCurrentId() === post.user_id; + var currentUser = UserStore.getCurrentUser(); + var isOwner = currentUser.id === post.user_id; + var isAdmin = utils.isAdmin(currentUser.roles); var timestamp = UserStore.getProfile(post.user_id).update_at; var channel = ChannelStore.get(post.channel_id); @@ -51,6 +60,11 @@ export default class RhsRootPost extends React.Component { currentUserCss = 'current--user'; } + var systemMessageClass = ''; + if (utils.isSystemMessage(post)) { + systemMessageClass = 'post--system'; + } + var channelName; if (channel) { if (channel.type === 'D') { @@ -60,12 +74,55 @@ export default class RhsRootPost extends React.Component { } } - var ownerOptions; + var dropdownContents = []; + if (isOwner) { - ownerOptions = ( - <div> - <a href='#' - className='dropdown-toggle theme' + dropdownContents.push( + <li + key='rhs-root-edit' + role='presentation' + > + <a + href='#' + role='menuitem' + data-toggle='modal' + data-target='#edit_post' + data-refocusid='#reply_textbox' + data-title={type} + data-message={post.message} + data-postid={post.id} + data-channelid={post.channel_id} + > + {'Edit'} + </a> + </li> + ); + } + + if (isOwner || isAdmin) { + dropdownContents.push( + <li + key='rhs-root-delete' + role='presentation' + > + <a + href='#' + role='menuitem' + onClick={() => EventHelpers.showDeletePostModal(post, this.props.commentCount)} + > + {'Delete'} + </a> + </li> + ); + } + + var rootOptions = ''; + if (dropdownContents.length > 0) { + rootOptions = ( + <div className='dropdown'> + <a + href='#' + className='post__dropdown dropdown-toggle' type='button' data-toggle='dropdown' aria-expanded='false' @@ -74,35 +131,7 @@ export default class RhsRootPost extends React.Component { className='dropdown-menu' role='menu' > - <li role='presentation'> - <a - href='#' - role='menuitem' - data-toggle='modal' - data-target='#edit_post' - data-refocusid='#reply_textbox' - data-title={type} - data-message={post.message} - data-postid={post.id} - data-channelid={post.channel_id} - > - Edit - </a> - </li> - <li role='presentation'> - <a - href='#' - role='menuitem' - data-toggle='modal' - data-target='#delete_post' - data-title={type} - data-postid={post.id} - data-channelid={post.channel_id} - data-comments={this.props.commentCount} - > - Delete - </a> - </li> + {dropdownContents} </ul> </div> ); @@ -133,7 +162,16 @@ export default class RhsRootPost extends React.Component { ); } - botIndicator = <li className='post-header-col post-header__name bot-indicator'>{'BOT'}</li>; + botIndicator = <li className='col col__name bot-indicator'>{'BOT'}</li>; + } else if (utils.isSystemMessage(post)) { + userProfile = ( + <UserProfile + userId={''} + overwriteName={Constants.SYSTEM_MESSAGE_PROFILE_NAME} + overwriteImage={Constants.SYSTEM_MESSAGE_PROFILE_IMAGE} + disablePopover={true} + /> + ); } let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); @@ -141,50 +179,52 @@ export default class RhsRootPost extends React.Component { if (post.props.override_icon_url) { src = post.props.override_icon_url; } + } else if (utils.isSystemMessage(post)) { + src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE; } const profilePic = ( - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={src} - height='36' - width='36' - /> - </div> + <img + className='post-profile-img' + src={src} + height='36' + width='36' + /> ); return ( - <div className={'post post--root ' + currentUserCss}> + <div className={'post post--root ' + currentUserCss + ' ' + systemMessageClass}> <div className='post-right-channel__name'>{channelName}</div> - <div className='post-profile-img__container'> - {profilePic} - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'><strong>{userProfile}</strong></li> - {botIndicator} - <li className='post-header-col'> - <time className='post-profile-time'> - {utils.displayCommentDateTime(post.create_at)} - </time> - </li> - <li className='post-header-col post-header__reply'> - <div className='dropdown'> - {ownerOptions} - </div> - </li> - </ul> - <div className='post-body'> - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> - <PostBodyAdditionalContent - post={post} - /> - {fileAttachment} + <div className='post__img'> + {profilePic} + </div> + <div> + <ul className='post__header'> + <li className='col__name'>{userProfile}</li> + {botIndicator} + <li className='col'> + <time className='post__time'> + {utils.displayCommentDateTime(post.create_at)} + </time> + </li> + <li className='col col__reply'> + <div> + {rootOptions} + </div> + </li> + </ul> + <div className='post__body'> + <div + ref='message_holder' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> + <PostBodyAdditionalContent + post={post} + /> + {fileAttachment} + </div> </div> </div> <hr /> diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index fe57bed28..2edcd8b37 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -1,17 +1,17 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PostStore = require('../stores/post_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var PreferenceStore = require('../stores/preference_store.jsx'); -var Utils = require('../utils/utils.jsx'); -var SearchBox = require('./search_bar.jsx'); -var CreateComment = require('./create_comment.jsx'); -var RhsHeaderPost = require('./rhs_header_post.jsx'); -var RootPost = require('./rhs_root_post.jsx'); -var Comment = require('./rhs_comment.jsx'); -var Constants = require('../utils/constants.jsx'); -var FileUploadOverlay = require('./file_upload_overlay.jsx'); +import PostStore from '../stores/post_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import * as Utils from '../utils/utils.jsx'; +import SearchBox from './search_bar.jsx'; +import CreateComment from './create_comment.jsx'; +import RhsHeaderPost from './rhs_header_post.jsx'; +import RootPost from './rhs_root_post.jsx'; +import Comment from './rhs_comment.jsx'; +import Constants from '../utils/constants.jsx'; +import FileUploadOverlay from './file_upload_overlay.jsx'; export default class RhsThread extends React.Component { constructor(props) { @@ -82,7 +82,7 @@ export default class RhsThread extends React.Component { } onChange() { var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } @@ -94,14 +94,22 @@ export default class RhsThread extends React.Component { return; } - var currentPosts = PostStore.getPosts(currentSelected.posts[currentSelected.order[0]].channel_id); + var currentPosts = PostStore.getVisiblePosts(currentSelected.posts[currentSelected.order[0]].channel_id); if (!currentPosts || currentPosts.order.length === 0) { return; } if (currentPosts.posts[currentPosts.order[0]].channel_id === currentSelected.posts[currentSelected.order[0]].channel_id) { - currentSelected.posts = {}; + for (var key in currentSelected.posts) { + if (currentSelected.posts.hasOwnProperty(key)) { + var post = currentSelected.posts[key]; + if (post.pending_post_id) { + Reflect.deleteProperty(currentSelected.posts, key); + } + } + } + for (var postId in currentPosts.posts) { if (currentPosts.posts.hasOwnProperty(postId)) { currentSelected.posts[postId] = currentPosts.posts[postId]; @@ -112,13 +120,11 @@ export default class RhsThread extends React.Component { } var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } resize() { - var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100; - $('.post-right__scroll').css('height', height + 'px'); $('.post-right__scroll').scrollTop(100000); if (this.state.windowWidth > 768) { $('.post-right__scroll').perfectScrollbar(); diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx deleted file mode 100644 index d245c6bac..000000000 --- a/web/react/components/search_autocomplete.jsx +++ /dev/null @@ -1,339 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -const ChannelStore = require('../stores/channel_store.jsx'); -const KeyCodes = require('../utils/constants.jsx').KeyCodes; -const Popover = ReactBootstrap.Popover; -const UserStore = require('../stores/user_store.jsx'); -const Utils = require('../utils/utils.jsx'); -const Constants = require('../utils/constants.jsx'); - -const patterns = new Map([ - ['channels', /\b(?:in|channel):\s*(\S*)$/i], - ['users', /\bfrom:\s*(\S*)$/i] -]); - -export default class SearchAutocomplete extends React.Component { - constructor(props) { - super(props); - - this.handleClick = this.handleClick.bind(this); - this.handleDocumentClick = this.handleDocumentClick.bind(this); - this.handleInputChange = this.handleInputChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - - this.completeWord = this.completeWord.bind(this); - this.getSelection = this.getSelection.bind(this); - this.scrollToItem = this.scrollToItem.bind(this); - this.updateSuggestions = this.updateSuggestions.bind(this); - - this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this); - this.renderUserSuggestion = this.renderUserSuggestion.bind(this); - - this.state = { - show: false, - mode: '', - filter: '', - selection: 0, - suggestions: new Map() - }; - } - - componentDidMount() { - $(document).on('click', this.handleDocumentClick); - } - - componentDidUpdate(prevProps, prevState) { - const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); - - if (this.state.show) { - if (!prevState.show) { - content.perfectScrollbar(); - content.css('max-height', $(window).height() - 200); - } - - // keep the keyboard selection visible when scrolling - this.scrollToItem(this.getSelection()); - } - } - - componentWillUnmount() { - $(document).off('click', this.handleDocumentClick); - } - - handleClick(value) { - this.completeWord(value); - } - - handleDocumentClick(e) { - const container = $(ReactDOM.findDOMNode(this.refs.searchPopover)); - - if (!(container.is(e.target) || container.has(e.target).length > 0)) { - this.setState({ - show: false - }); - } - } - - handleInputChange(textbox, text) { - const caret = Utils.getCaretPosition(textbox); - const preText = text.substring(0, caret); - - let mode = ''; - let filter = ''; - for (const [modeForPattern, pattern] of patterns) { - const result = pattern.exec(preText); - - if (result) { - mode = modeForPattern; - filter = result[1]; - break; - } - } - - if (mode !== this.state.mode || filter !== this.state.filter) { - this.updateSuggestions(mode, filter); - } - - this.setState({ - mode, - filter, - show: mode || filter - }); - } - - handleKeyDown(e) { - if (!this.state.show || this.state.suggestions.length === 0) { - return; - } - - if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) { - e.preventDefault(); - - let selection = this.state.selection; - - if (e.which === KeyCodes.UP) { - selection -= 1; - } else { - selection += 1; - } - - if (selection >= 0 && selection < this.state.suggestions.length) { - this.setState({ - selection - }); - } - } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { - e.preventDefault(); - - this.completeWord(this.getSelection()); - } - } - - completeWord(value) { - // add a space so that anything else typed doesn't interfere with the search flag - this.props.completeWord(this.state.filter, value + ' '); - - this.setState({ - show: false, - mode: '', - filter: '', - selection: 0 - }); - } - - getSelection() { - if (this.state.mode === 'channels') { - return this.state.suggestions[this.state.selection].name; - } else if (this.state.mode === 'users') { - return this.state.suggestions[this.state.selection].username; - } - - return ''; - } - - scrollToItem(itemName) { - const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); - const visibleContentHeight = content[0].clientHeight; - const actualContentHeight = content[0].scrollHeight; - - if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) { - const contentTop = content.scrollTop(); - const contentTopPadding = parseInt(content.css('padding-top'), 10); - const contentBottomPadding = parseInt(content.css('padding-top'), 10); - - const item = $(this.refs[itemName]); - const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); - const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10); - - if (itemTop - contentTopPadding < contentTop) { - // the item is off the top of the visible space - content.scrollTop(itemTop - contentTopPadding); - } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { - // the item has gone off the bottom of the visible space - content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); - } - } - } - - updateSuggestions(mode, filter) { - let suggestions = []; - - if (mode === 'channels') { - let channels = ChannelStore.getAll(); - - if (filter) { - channels = channels.filter((channel) => channel.name.startsWith(filter) && channel.type !== 'D'); - } else { - // don't show direct channels - channels = channels.filter((channel) => channel.type !== 'D'); - } - - channels.sort((a, b) => { - // put public channels first and then sort alphabebetically - if (a.type === b.type) { - return a.name.localeCompare(b.name); - } else if (a.type === Constants.OPEN_CHANNEL) { - return -1; - } - - return 1; - }); - - suggestions = channels; - } else if (mode === 'users') { - let users = UserStore.getActiveOnlyProfileList(); - - if (filter) { - users = users.filter((user) => user.username.startsWith(filter)); - } - - users.sort((a, b) => a.username.localeCompare(b.username)); - - suggestions = users; - } - - let selection = this.state.selection; - - // keep the same user/channel selected if it's still visible as a suggestion - if (selection > 0 && this.state.suggestions.length > 0) { - // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects - const currentSelectionId = this.state.suggestions[selection].id; - let found = false; - - for (let i = 0; i < suggestions.length; i++) { - if (suggestions[i].id === currentSelectionId) { - selection = i; - found = true; - - break; - } - } - - if (!found) { - selection = 0; - } - } else { - selection = 0; - } - - this.setState({ - suggestions, - selection - }); - } - - renderChannelSuggestion(channel) { - let className = 'search-autocomplete__item'; - if (channel.name === this.getSelection()) { - className += ' selected'; - } - - return ( - <div - key={channel.name} - ref={channel.name} - onClick={this.handleClick.bind(this, channel.name)} - className={className} - > - {channel.name} - </div> - ); - } - - renderUserSuggestion(user) { - let className = 'search-autocomplete__item'; - if (user.username === this.getSelection()) { - className += ' selected'; - } - - return ( - <div - key={user.username} - ref={user.username} - onClick={this.handleClick.bind(this, user.username)} - className={className} - > - <img - className='profile-img rounded' - src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} - /> - {user.username} - </div> - ); - } - - render() { - if (!this.state.show || this.state.suggestions.length === 0) { - return null; - } - - let suggestions = []; - - if (this.state.mode === 'channels') { - const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL); - if (publicChannels.length > 0) { - suggestions.push( - <div - key='public-channel-divider' - className='search-autocomplete__divider' - > - <span>{'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'}</span> - </div> - ); - suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion)); - } - - const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); - if (privateChannels.length > 0) { - suggestions.push( - <div - key='private-channel-divider' - className='search-autocomplete__divider' - > - <span>{'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'}</span> - </div> - ); - suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion)); - } - } else if (this.state.mode === 'users') { - suggestions = this.state.suggestions.map(this.renderUserSuggestion); - } - - return ( - <Popover - ref='searchPopover' - onShow={this.componentDidMount} - id='search-autocomplete__popover' - className='search-help-popover autocomplete visible' - placement='bottom' - > - {suggestions} - </Popover> - ); - } -} - -SearchAutocomplete.propTypes = { - completeWord: React.PropTypes.func.isRequired -}; diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 90865475b..77c9e39b9 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -1,15 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.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'); +import * as client from '../utils/client.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import SearchStore from '../stores/search_store.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import SuggestionBox from './suggestion/suggestion_box.jsx'; +import SearchChannelProvider from './suggestion/search_channel_provider.jsx'; +import SearchSuggestionList from './suggestion/search_suggestion_list.jsx'; +import SearchUserProvider from './suggestion/search_user_provider.jsx'; +import * as utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; var Popover = ReactBootstrap.Popover; -var SearchAutocomplete = require('./search_autocomplete.jsx'); export default class SearchBar extends React.Component { constructor() { @@ -17,17 +20,17 @@ export default class SearchBar extends React.Component { this.mounted = false; this.onListenerChange = this.onListenerChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleUserFocus = this.handleUserFocus.bind(this); this.handleUserBlur = this.handleUserBlur.bind(this); this.performSearch = this.performSearch.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - this.completeWord = this.completeWord.bind(this); const state = this.getSearchTermStateFromStores(); state.focused = false; this.state = state; + + this.suggestionProviders = [new SearchChannelProvider(), new SearchUserProvider()]; } getSearchTermStateFromStores() { var term = SearchStore.getSearchTerm() || ''; @@ -46,7 +49,7 @@ export default class SearchBar extends React.Component { onListenerChange(doSearch, isMentionSearch) { if (this.mounted) { var newState = this.getSearchTermStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } if (doSearch) { @@ -77,18 +80,11 @@ export default class SearchBar extends React.Component { results: null }); } - handleKeyDown(e) { - if (this.refs.autocomplete) { - this.refs.autocomplete.handleKeyDown(e); - } - } - handleUserInput(e) { - var term = e.target.value; + handleUserInput(text) { + var term = text; SearchStore.storeSearchTerm(term); SearchStore.emitSearchTermChange(false); this.setState({searchTerm: term}); - - this.refs.autocomplete.handleInputChange(e.target, term); } handleUserBlur() { this.setState({focused: false}); @@ -128,23 +124,6 @@ export default class SearchBar extends React.Component { this.performSearch(this.state.searchTerm.trim()); } - completeWord(partialWord, word) { - const textbox = ReactDOM.findDOMNode(this.refs.search); - let text = textbox.value; - - const caret = utils.getCaretPosition(textbox); - const preText = text.substring(0, caret - partialWord.length); - const postText = text.substring(caret); - text = preText + word + postText; - - textbox.value = text; - utils.setCaretPosition(textbox, preText.length + word.length); - - SearchStore.storeSearchTerm(text); - SearchStore.emitSearchTermChange(false); - this.setState({searchTerm: text}); - } - render() { var isSearching = null; if (this.state.isSearching) { @@ -178,22 +157,18 @@ export default class SearchBar extends React.Component { autoComplete='off' > <span className='glyphicon glyphicon-search sidebar__search-icon' /> - <input - type='text' + <SuggestionBox ref='search' className='form-control search-bar' placeholder='Search' value={this.state.searchTerm} onFocus={this.handleUserFocus} onBlur={this.handleUserBlur} - onChange={this.handleUserInput} - onKeyDown={this.handleKeyDown} + onUserInput={this.handleUserInput} + listComponent={SearchSuggestionList} + providers={this.suggestionProviders} /> {isSearching} - <SearchAutocomplete - ref='autocomplete' - completeWord={this.completeWord} - /> <Popover id='searchbar-help-popup' placement='bottom' diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx index b56a7b006..141181701 100644 --- a/web/react/components/search_results.jsx +++ b/web/react/components/search_results.jsx @@ -1,12 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -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'); -var SearchResultsHeader = require('./search_results_header.jsx'); -var SearchResultsItem = require('./search_results_item.jsx'); +import SearchStore from '../stores/search_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import SearchBox from './search_bar.jsx'; +import * as Utils from '../utils/utils.jsx'; +import SearchResultsHeader from './search_results_header.jsx'; +import SearchResultsItem from './search_results_item.jsx'; function getStateFromStores() { return {results: SearchStore.getSearchResults()}; @@ -55,15 +55,13 @@ export default class SearchResults extends React.Component { onChange() { if (this.mounted) { var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } } resize() { - var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100; - $('#search-items-container').css('height', height + 'px'); $('#search-items-container').scrollTop(0); if (this.state.windowWidth > 768) { $('#search-items-container').perfectScrollbar(); @@ -82,14 +80,27 @@ export default class SearchResults extends React.Component { var ctls = null; - if (noResults) { + if (!searchTerm && noResults) { + ctls = ( + <div className='sidebar--right__subheader'> + <ul> + <li> + {'Use '}<b>{'"quotation marks"'}</b>{' to search for phrases'} + </li> + <li> + {'Use '}<b>{'from:'}</b>{' to find posts from specific users and '}<b>{'in:'}</b>{' to find posts in specific channels'} + </li> + </ul> + </div> + ); + } else if (noResults) { ctls = ( <div className='sidebar--right__subheader'> <h4>{'NO RESULTS'}</h4> <ul> - <li>If you're searching a partial phrase (ex. searching "rea", looking for "reach" or "reaction"), append a * to your search term</li> - <li>Due to the volume of results, two letter searches and common words like "this", "a" and "is" won't appear in search results</li> + <li>{'If you\'re searching a partial phrase (ex. searching "rea", looking for "reach" or "reaction"), append a * to your search term'}</li> + <li>{'Due to the volume of results, two letter searches and common words like "this", "a" and "is" won\'t appear in search results'}</li> </ul> </div> ); diff --git a/web/react/components/search_results_header.jsx b/web/react/components/search_results_header.jsx index fdd449c2d..581976494 100644 --- a/web/react/components/search_results_header.jsx +++ b/web/react/components/search_results_header.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; export default class SearchResultsHeader extends React.Component { diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx index a8bd4db2c..f71abf971 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -1,54 +1,38 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -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'); -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); -var TextFormatting = require('../utils/text_formatting.jsx'); -var ActionTypes = Constants.ActionTypes; +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import UserProfile from './user_profile.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import * as utils from '../utils/utils.jsx'; +import * as TextFormatting from '../utils/text_formatting.jsx'; + +import Constants from '../utils/constants.jsx'; export default class SearchResultsItem extends React.Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); + this.handleFocusRHSClick = this.handleFocusRHSClick.bind(this); } handleClick(e) { e.preventDefault(); - var self = this; - - client.getPost( - this.props.post.channel_id, - this.props.post.id, - function success(data) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POST_SELECTED, - post_list: data, - from_search: SearchStore.getSearchTerm() - }); + EventHelpers.emitPostFocusEvent(this.props.post.id); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_SEARCH, - results: null, - is_mention_search: self.props.isMentionSearch - }); - }, - function success(err) { - AsyncClient.dispatchError(err, 'getPost'); - } - ); + if ($(window).width() < 768) { + $('.sidebar--right').removeClass('move--left'); + $('.inner__wrap').removeClass('move--left'); + } + } - var postChannel = ChannelStore.get(this.props.post.channel_id); + handleFocusRHSClick(e) { + e.preventDefault(); - utils.switchChannel(postChannel); + EventHelpers.emitPostFocusRightHandSideEvent(this.props.post); } render() { @@ -71,32 +55,52 @@ export default class SearchResultsItem extends React.Component { return ( <div className='search-item-container post' - onClick={this.handleClick} > <div className='search-channel__name'>{channelName}</div> - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()} - height='36' - width='36' - /> - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className='post-header-col'> - <time className='search-item-time'> - {utils.displayDate(this.props.post.create_at) + ' ' + utils.displayTime(this.props.post.create_at)} - </time> - </li> - </ul> - <div className='search-item-snippet'> - <span - onClick={this.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}} + <div className='post__img'> + <img + src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()} + height='36' + width='36' /> </div> + <div> + <ul className='post__header'> + <li className='col__name'><strong><UserProfile userId={this.props.post.user_id} /></strong></li> + <li className='col'> + <time className='search-item-time'> + {utils.displayDate(this.props.post.create_at) + ' ' + utils.displayTime(this.props.post.create_at)} + </time> + </li> + <li> + <a + href='#' + className='search-item__jump' + onClick={this.handleClick} + > + {'Jump'} + </a> + </li> + <li> + <a + href='#' + className='comment-icon__container search-item__comment' + onClick={this.handleFocusRHSClick} + > + <span + className='comment-icon' + dangerouslySetInnerHTML={{__html: Constants.COMMENT_ICON}} + /> + </a> + </li> + </ul> + <div className='search-item-snippet'> + <span + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}} + /> + </div> + </div> </div> </div> ); diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx index 68d9cea48..4af46c35a 100644 --- a/web/react/components/settings_sidebar.jsx +++ b/web/react/components/settings_sidebar.jsx @@ -1,14 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); export default class SettingsSidebar extends React.Component { componentDidUpdate() { $('.settings-modal').find('.modal-body').scrollTop(0); $('.settings-modal').find('.modal-body').perfectScrollbar('update'); - if (utils.isSafari()) { - $('.settings-modal .settings-links .nav').addClass('absolute'); - } } constructor(props) { super(props); diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 0b1abe4fe..8393440cb 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -1,26 +1,24 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const NewChannelFlow = require('./new_channel_flow.jsx'); -const MoreDirectChannels = require('./more_direct_channels.jsx'); -const SearchBox = require('./search_bar.jsx'); -const SidebarHeader = require('./sidebar_header.jsx'); -const UnreadChannelIndicator = require('./unread_channel_indicator.jsx'); -const TutorialTip = require('./tutorial/tutorial_tip.jsx'); - -const ChannelStore = require('../stores/channel_store.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const TeamStore = require('../stores/team_store.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); - -const AsyncClient = require('../utils/async_client.jsx'); -const Client = require('../utils/client.jsx'); -const Utils = require('../utils/utils.jsx'); - -const Constants = require('../utils/constants.jsx'); +import NewChannelFlow from './new_channel_flow.jsx'; +import MoreDirectChannels from './more_direct_channels.jsx'; +import SidebarHeader from './sidebar_header.jsx'; +import UnreadChannelIndicator from './unread_channel_indicator.jsx'; +import TutorialTip from './tutorial/tutorial_tip.jsx'; + +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; + +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; + +import Constants from '../utils/constants.jsx'; const Preferences = Constants.Preferences; const TutorialSteps = Constants.TutorialSteps; -const NotificationPrefs = Constants.NotificationPrefs; const Tooltip = ReactBootstrap.Tooltip; const OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -39,7 +37,6 @@ export default class Sidebar extends React.Component { this.onScroll = this.onScroll.bind(this); this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this); this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this); - this.updateScrollbar = this.updateScrollbar.bind(this); this.handleResize = this.handleResize.bind(this); this.showNewChannelModal = this.showNewChannelModal.bind(this); @@ -49,8 +46,6 @@ export default class Sidebar extends React.Component { 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(); @@ -60,43 +55,15 @@ export default class Sidebar extends React.Component { 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; - } else if (chMember.notify_props && chMember.notify_props.mark_unread === NotificationPrefs.MENTION) { - chUnreadCount = 0; - } - - channelUnreadCounts[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount}; - }); - - this.unreadCountPerChannel = channelUnreadCounts; } - getUnreadCount(channelId) { - let mentions = 0; + getTotalUnreadCount() { let msgs = 0; + let mentions = 0; + const unreadCounts = this.state.unreadCounts; - 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; + Object.keys(unreadCounts).forEach((chId) => { + msgs += unreadCounts[chId].msgs; + mentions += unreadCounts[chId].mentions; }); return {msgs, mentions}; @@ -104,47 +71,47 @@ export default class Sidebar extends React.Component { getStateFromStores() { const members = ChannelStore.getAllMembers(); const currentChannelId = ChannelStore.getCurrentId(); + const currentUserId = UserStore.getCurrentId(); const channels = Object.assign([], ChannelStore.getAll()); + channels.sort((a, b) => a.display_name.localeCompare(b.display_name)); + const publicChannels = channels.filter((channel) => channel.type === Constants.OPEN_CHANNEL); const privateChannels = channels.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); - const directChannels = channels.filter((channel) => channel.type === Constants.DM_CHANNEL); const preferences = PreferenceStore.getPreferences(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); - var visibleDirectChannels = []; - for (var i = 0; i < directChannels.length; i++) { - const dm = directChannels[i]; - const teammate = Utils.getDirectTeammate(dm.id); - if (!teammate) { + const directChannels = []; + for (const preference of preferences) { + if (preference.value !== 'true') { continue; } - const member = members[dm.id]; - const msgCount = dm.total_msg_count - member.msg_count; + const teammateId = preference.name; - // always show a channel if either it is the current one or if it is unread, but it is not currently being left - const forceShow = (currentChannelId === dm.id || msgCount > 0) && !this.isLeaving.get(dm.id); - const preferenceShow = preferences.some((preference) => (preference.name === teammate.id && preference.value !== 'false')); + let directChannel = channels.find(Utils.isDirectChannelForUser.bind(null, teammateId)); - if (preferenceShow || forceShow) { - dm.display_name = Utils.displayUsername(teammate.id); - dm.teammate_id = teammate.id; - dm.status = UserStore.getStatus(teammate.id); + // a direct channel doesn't exist yet so create a fake one + if (!directChannel) { + directChannel = { + name: Utils.getDirectChannelName(currentUserId, teammateId), + last_post_at: 0, + total_msg_count: 0, + type: Constants.DM_CHANNEL, + fake: true + }; + } - visibleDirectChannels.push(dm); + directChannel.display_name = Utils.displayUsername(teammateId); + directChannel.teammate_id = teammateId; + directChannel.status = UserStore.getStatus(teammateId); - if (forceShow && !preferenceShow) { - // make sure that unread direct channels are visible - const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); - AsyncClient.savePreferences([preference]); - } - } + directChannels.push(directChannel); } - const hiddenDirectChannelCount = UserStore.getActiveOnlyProfileList(true).length - visibleDirectChannels.length; + directChannels.sort(this.sortChannelsByDisplayName); - visibleDirectChannels.sort(this.sortChannelsByDisplayName); + const hiddenDirectChannelCount = UserStore.getActiveOnlyProfileList(true).length - directChannels.length; const tutorialPref = PreferenceStore.getPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), {value: '999'}); @@ -153,8 +120,9 @@ export default class Sidebar extends React.Component { members, publicChannels, privateChannels, - visibleDirectChannels, + directChannels, hiddenDirectChannelCount, + unreadCounts: JSON.parse(JSON.stringify(ChannelStore.getUnreadCounts())), showTutorialTip: parseInt(tutorialPref.value, 10) === TutorialSteps.CHANNEL_POPOVER }; } @@ -168,12 +136,15 @@ export default class Sidebar extends React.Component { this.updateTitle(); this.updateUnreadIndicators(); - this.updateScrollbar(); window.addEventListener('resize', this.handleResize); + + if ($(window).width() > 768) { + $('.nav-pills__container').perfectScrollbar(); + } } shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areStatesEqual(nextState, this.state)) { + if (!Utils.areObjectsEqual(nextState, this.state)) { return true; } return false; @@ -181,7 +152,6 @@ export default class Sidebar extends React.Component { componentDidUpdate() { this.updateTitle(); this.updateUnreadIndicators(); - this.updateScrollbar(); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); @@ -198,17 +168,8 @@ export default class Sidebar extends React.Component { windowHeight: Utils.windowHeight() }); } - updateScrollbar() { - if (this.state.windowWidth > 768) { - $('.nav-pills__container').perfectScrollbar(); - $('.nav-pills__container').perfectScrollbar('update'); - } - } onChange() { - var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } + this.setState(this.getStateFromStores()); } updateTitle() { const channel = ChannelStore.getCurrent(); @@ -223,7 +184,7 @@ export default class Sidebar extends React.Component { currentChannelName = Utils.getDirectTeammate(channel.id).username; } - const unread = this.getUnreadCount(); + const unread = this.getTotalUnreadCount(); const mentionTitle = unread.mentions > 0 ? '(' + unread.mentions + ') ' : ''; const unreadTitle = unread.msgs > 0 ? '* ' : ''; document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + TeamStore.getCurrent().display_name + ' ' + currentSiteName; @@ -349,13 +310,13 @@ export default class Sidebar extends React.Component { } createChannelElement(channel, index, arr, handleClose) { - var members = this.state.members; - var activeId = this.state.activeId; - var channelMember = members[channel.id]; - var unreadCount = this.getUnreadCount(channel.id); - var msgCount; + const members = this.state.members; + const activeId = this.state.activeId; + const channelMember = members[channel.id]; + const unreadCount = this.state.unreadCounts[channel.id] || {msgs: 0, mentions: 0}; + let msgCount; - var linkClass = ''; + let linkClass = ''; if (channel.id === activeId) { linkClass = 'active'; } @@ -512,8 +473,6 @@ 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; @@ -523,7 +482,7 @@ export default class Sidebar extends React.Component { const privateChannelItems = this.state.privateChannels.map(this.createChannelElement); - const directMessageItems = this.state.visibleDirectChannels.map((channel, index, arr) => { + const directMessageItems = this.state.directChannels.map((channel, index, arr) => { return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); }); @@ -587,7 +546,6 @@ export default class Sidebar extends React.Component { teamName={TeamStore.getCurrent().name} teamType={TeamStore.getCurrent().type} /> - <SearchBox /> <UnreadChannelIndicator show={this.state.showTopUnread} diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index bc7f6ba50..20f4fd511 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -1,14 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const NavbarDropdown = require('./navbar_dropdown.jsx'); -const TutorialTip = require('./tutorial/tutorial_tip.jsx'); +import NavbarDropdown from './navbar_dropdown.jsx'; +import TutorialTip from './tutorial/tutorial_tip.jsx'; -const UserStore = require('../stores/user_store.jsx'); -const PreferenceStore = require('../stores/preference_store.jsx'); +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; -const Utils = require('../utils/utils.jsx'); -const Constants = require('../utils/constants.jsx'); +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; const Preferences = Constants.Preferences; const TutorialSteps = Constants.TutorialSteps; diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx index e2ef60959..ac1049da0 100644 --- a/web/react/components/sidebar_right.jsx +++ b/web/react/components/sidebar_right.jsx @@ -1,15 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -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'); +import SearchResults from './search_results.jsx'; +import RhsThread from './rhs_thread.jsx'; +import SearchStore from '../stores/search_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import * as Utils from '../utils/utils.jsx'; -function getStateFromStores() { - return {search_visible: SearchStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: SearchStore.getIsMentionSearch()}; -} +const SIDEBAR_SCROLL_DELAY = 500; export default class SidebarRight extends React.Component { constructor(props) { @@ -19,22 +17,37 @@ export default class SidebarRight extends React.Component { this.onSelectedChange = this.onSelectedChange.bind(this); this.onSearchChange = this.onSearchChange.bind(this); + this.onShowSearch = this.onShowSearch.bind(this); this.doStrangeThings = this.doStrangeThings.bind(this); - this.state = getStateFromStores(); + this.state = this.getStateFromStores(); + } + getStateFromStores() { + return { + search_visible: SearchStore.getSearchResults() != null, + post_right_visible: PostStore.getSelectedPost() != null, + is_mention_search: SearchStore.getIsMentionSearch() + }; } componentDidMount() { SearchStore.addSearchChangeListener(this.onSearchChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange); + SearchStore.addShowSearchListener(this.onShowSearch); this.doStrangeThings(); } componentWillUnmount() { SearchStore.removeSearchChangeListener(this.onSearchChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange); + SearchStore.removeShowSearchListener(this.onShowSearch); } - componentWillUpdate() { - PostStore.jumpPostsViewSidebarOpen(); + componentWillUpdate(nextProps, nextState) { + const isOpen = this.state.search_visible || this.state.post_right_visible; + const willOpen = nextState.search_visible || nextState.post_right_visible; + + if (!isOpen && willOpen) { + setTimeout(() => PostStore.jumpPostsViewSidebarOpen(), SIDEBAR_SCROLL_DELAY); + } } doStrangeThings() { // We should have a better way to do this stuff @@ -64,18 +77,25 @@ export default class SidebarRight extends React.Component { this.doStrangeThings(); } onSelectedChange(fromSearch) { - var newState = getStateFromStores(fromSearch); + var newState = this.getStateFromStores(fromSearch); newState.from_search = fromSearch; - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } onSearchChange() { - var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + var newState = this.getStateFromStores(); + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } + onShowSearch() { + if (!this.state.search_visible) { + this.setState({ + search_visible: true + }); + } + } render() { var content = ''; diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index 2135e3ef3..20c2bf696 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -1,12 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var InviteMemberModal = require('./invite_member_modal.jsx'); -var UserSettingsModal = require('./user_settings/user_settings_modal.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var client = require('../utils/client.jsx'); -var utils = require('../utils/utils.jsx'); +import TeamMembersModal from './team_members_modal.jsx'; +import ToggleModalButton from './toggle_modal_button.jsx'; +import UserSettingsModal from './user_settings/user_settings_modal.jsx'; +import UserStore from '../stores/user_store.jsx'; +import * as client from '../utils/client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import * as utils from '../utils/utils.jsx'; export default class SidebarRightMenu extends React.Component { componentDidMount() { @@ -46,9 +47,9 @@ export default class SidebarRightMenu extends React.Component { <li> <a href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > - <i className='glyphicon glyphicon-user'></i>Invite New Member + <i className='fa fa-user'></i>{'Invite New Member'} </a> </li> ); @@ -56,12 +57,12 @@ export default class SidebarRightMenu extends React.Component { if (this.props.teamType === 'O') { teamLink = ( <li> - <a href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id} - ><i className='glyphicon glyphicon-link'></i>Get Team Invite Link</a> + <a + href='#' + onClick={EventHelpers.showGetTeamInviteLinkModal} + > + <i className='glyphicon glyphicon-link'></i>{'Get Team Invite Link'} + </a> </li> ); } @@ -74,28 +75,25 @@ export default class SidebarRightMenu extends React.Component { href='#' data-toggle='modal' data-target='#team_settings' - ><i className='glyphicon glyphicon-globe'></i>Team Settings</a> + ><i className='fa fa-globe'></i>{'Team Settings'}</a> </li> ); manageLink = ( <li> - <a - href='#' - data-toggle='modal' - data-target='#team_members' - > - <i className='glyphicon glyphicon-wrench'></i>Manage Members</a> + <ToggleModalButton dialogType={TeamMembersModal}> + <i className='fa fa-users'></i>{'Manage Members'} + </ToggleModalButton> </li> ); } - if (isSystemAdmin) { + if (isSystemAdmin && !utils.isMobile()) { consoleLink = ( <li> <a href={'/admin_console?' + utils.getSessionIndex()} > - <i className='glyphicon glyphicon-wrench'></i>System Console</a> + <i className='fa fa-wrench'></i>{'System Console'}</a> </li> ); } @@ -109,6 +107,27 @@ export default class SidebarRightMenu extends React.Component { teamDisplayName = this.props.teamDisplayName; } + let helpLink = null; + if (global.window.mm_config.HelpLink) { + helpLink = ( + <li> + <a + target='_blank' + href={global.window.mm_config.HelpLink} + ><i className='fa fa-question'></i>{'Help'}</a></li> + ); + } + + let reportLink = null; + if (global.window.mm_config.ReportAProblemLink) { + reportLink = ( + <li> + <a + target='_blank' + href={global.window.mm_config.ReportAProblemLink} + ><i className='fa fa-phone'></i>{'Report a Problem'}</a></li> + ); + } return ( <div> <div className='team__header theme'> @@ -125,7 +144,7 @@ export default class SidebarRightMenu extends React.Component { href='#' onClick={() => this.setState({showUserSettingsModal: true})} > - <i className='glyphicon glyphicon-cog'></i>Account Settings + <i className='fa fa-cog'></i>{'Account Settings'} </a> </li> {teamSettingsLink} @@ -137,18 +156,10 @@ export default class SidebarRightMenu extends React.Component { <a href='#' onClick={this.handleLogoutClick} - ><i className='glyphicon glyphicon-log-out'></i>Logout</a></li> + ><i className='fa fa-sign-out'></i>{'Logout'}</a></li> <li className='divider'></li> - <li> - <a - target='_blank' - href='/static/help/configure_links.html' - ><i className='glyphicon glyphicon-question-sign'></i>Help</a></li> - <li> - <a - target='_blank' - href='/static/help/configure_links.html' - ><i className='glyphicon glyphicon-earphone'></i>Report a Problem</a></li> + {helpLink} + {reportLink} </ul> </div> <UserSettingsModal diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx index 516765a3f..0ac837326 100644 --- a/web/react/components/signup_team.jsx +++ b/web/react/components/signup_team.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const ChoosePage = require('./team_signup_choose_auth.jsx'); -const EmailSignUpPage = require('./team_signup_with_email.jsx'); -const SSOSignupPage = require('./team_signup_with_sso.jsx'); -const Constants = require('../utils/constants.jsx'); +import ChoosePage from './team_signup_choose_auth.jsx'; +import EmailSignUpPage from './team_signup_with_email.jsx'; +import SSOSignupPage from './team_signup_with_sso.jsx'; +import Constants from '../utils/constants.jsx'; export default class TeamSignUp extends React.Component { constructor(props) { diff --git a/web/react/components/signup_team_complete.jsx b/web/react/components/signup_team_complete.jsx index c30132885..6c7fd57b3 100644 --- a/web/react/components/signup_team_complete.jsx +++ b/web/react/components/signup_team_complete.jsx @@ -1,13 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var WelcomePage = require('./team_signup_welcome_page.jsx'); -var TeamDisplayNamePage = require('./team_signup_display_name_page.jsx'); -var TeamURLPage = require('./team_signup_url_page.jsx'); -var SendInivtesPage = require('./team_signup_send_invites_page.jsx'); -var UsernamePage = require('./team_signup_username_page.jsx'); -var PasswordPage = require('./team_signup_password_page.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); +import WelcomePage from './team_signup_welcome_page.jsx'; +import TeamDisplayNamePage from './team_signup_display_name_page.jsx'; +import TeamURLPage from './team_signup_url_page.jsx'; +import SendInivtesPage from './team_signup_send_invites_page.jsx'; +import UsernamePage from './team_signup_username_page.jsx'; +import PasswordPage from './team_signup_password_page.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; export default class SignupTeamComplete extends React.Component { constructor(props) { diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx index d70ea5065..2bde78726 100644 --- a/web/react/components/signup_user_complete.jsx +++ b/web/react/components/signup_user_complete.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; export default class SignupUserComplete extends React.Component { constructor(props) { diff --git a/web/react/components/suggestion/at_mention_provider.jsx b/web/react/components/suggestion/at_mention_provider.jsx new file mode 100644 index 000000000..8c2893448 --- /dev/null +++ b/web/react/components/suggestion/at_mention_provider.jsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +class AtMentionSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let username; + let description; + let icon; + if (item.username === 'all') { + username = 'all'; + description = 'Notifies everyone in the team'; + icon = <i className='mention-img fa fa-users fa-2x' />; + } else if (item.username === 'channel') { + username = 'channel'; + description = 'Notifies everyone in the channel'; + icon = <i className='mention-img fa fa-users fa-2x' />; + } else { + username = item.username; + description = Utils.getFullName(item); + icon = ( + <img + className='mention-img' + src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at + '&' + Utils.getSessionIndex()} + /> + ); + } + + let className = 'mentions-name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <div className='pull-left'> + {icon} + </div> + <div className='pull-left mention-align'> + <span> + {'@' + username} + </span> + <span className='mention-fullname'> + {description} + </span> + </div> + </div> + ); + } +} + +AtMentionSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class AtMentionProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/@([a-z0-9\-\._]*)$/i).exec(pretext); + if (captured) { + const usernamePrefix = captured[1]; + + const users = UserStore.getProfiles(); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix)) { + filtered.push(user); + } + } + + // add dummy users to represent the @all and @channel special mentions + if ('all'.startsWith(usernamePrefix)) { + filtered.push({username: 'all'}); + } + + if ('channel'.startsWith(usernamePrefix)) { + filtered.push({username: 'channel'}); + } + + filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); + + const mentions = filtered.map((user) => '@' + user.username); + + SuggestionStore.setMatchedPretext(suggestionId, captured[0]); + SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion); + } + } +} diff --git a/web/react/components/suggestion/command_provider.jsx b/web/react/components/suggestion/command_provider.jsx new file mode 100644 index 000000000..91d556bb9 --- /dev/null +++ b/web/react/components/suggestion/command_provider.jsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as AsyncClient from '../../utils/async_client.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +class CommandSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'command-name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <div className='command__title'> + <string>{item.suggestion}</string> + </div> + <div className='command__desc'> + {item.description} + </div> + </div> + ); + } +} + +CommandSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class CommandProvider { + handlePretextChanged(suggestionId, pretext) { + if (pretext.startsWith('/')) { + SuggestionStore.setMatchedPretext(suggestionId, pretext); + + AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion); + } + } +} diff --git a/web/react/components/suggestion/emoticon_provider.jsx b/web/react/components/suggestion/emoticon_provider.jsx new file mode 100644 index 000000000..fd470cf21 --- /dev/null +++ b/web/react/components/suggestion/emoticon_provider.jsx @@ -0,0 +1,91 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import * as Emoticons from '../../utils/emoticons.jsx'; + +const MAX_EMOTICON_SUGGESTIONS = 40; + +class EmoticonSuggestion extends React.Component { + render() { + const text = this.props.term; + const name = this.props.item; + + let className = 'emoticon-suggestion'; + if (this.props.isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={this.props.onClick} + > + <div className='pull-left'> + <img + alt={text} + className='emoticon-suggestion__image' + src={Emoticons.getImagePathForEmoticon(name)} + title={text} + /> + </div> + <div className='pull-left'> + {text} + </div> + </div> + ); + } +} + +EmoticonSuggestion.propTypes = { + item: React.PropTypes.string.isRequired, + term: React.PropTypes.string.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class EmoticonProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/(?:^|\s)(:([a-zA-Z0-9_+\-]*))$/g).exec(pretext); + if (captured) { + const text = captured[1]; + const partialName = captured[2]; + + const names = []; + + for (const emoticon of Emoticons.emoticonMap.keys()) { + if (emoticon.indexOf(partialName) !== -1) { + names.push(emoticon); + + if (names.length >= MAX_EMOTICON_SUGGESTIONS) { + break; + } + } + } + + // sort the emoticons so that emoticons starting with the entered text come first + names.sort((a, b) => { + const aPrefix = a.startsWith(partialName); + const bPrefix = b.startsWith(partialName); + + if (aPrefix === bPrefix) { + return a.localeCompare(b); + } else if (aPrefix) { + return -1; + } + + return 1; + }); + + const terms = names.map((name) => ':' + name + ':'); + + if (terms.length > 0) { + SuggestionStore.setMatchedPretext(suggestionId, text); + SuggestionStore.addSuggestions(suggestionId, terms, names, EmoticonSuggestion); + + // force the selection to be cleared since the order of elements may have changed + SuggestionStore.clearSelection(suggestionId); + } + } + } +} diff --git a/web/react/components/suggestion/search_channel_provider.jsx b/web/react/components/suggestion/search_channel_provider.jsx new file mode 100644 index 000000000..7547a9341 --- /dev/null +++ b/web/react/components/suggestion/search_channel_provider.jsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelStore from '../../stores/channel_store.jsx'; +import Constants from '../../utils/constants.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +class SearchChannelSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + onClick={onClick} + className={className} + > + {item.name} + </div> + ); + } +} + +SearchChannelSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchChannelProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext); + if (captured) { + const channelPrefix = captured[1]; + + const channels = ChannelStore.getAll(); + const publicChannels = []; + const privateChannels = []; + + for (const id of Object.keys(channels)) { + const channel = channels[id]; + + // don't show direct channels + if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) { + if (channel.type === Constants.OPEN_CHANNEL) { + publicChannels.push(channel); + } else { + privateChannels.push(channel); + } + } + } + + publicChannels.sort((a, b) => a.name.localeCompare(b.name)); + const publicChannelNames = publicChannels.map((channel) => channel.name); + + privateChannels.sort((a, b) => a.name.localeCompare(b.name)); + const privateChannelNames = privateChannels.map((channel) => channel.name); + + SuggestionStore.setMatchedPretext(suggestionId, channelPrefix); + + SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion); + SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion); + } + } +} diff --git a/web/react/components/suggestion/search_suggestion_list.jsx b/web/react/components/suggestion/search_suggestion_list.jsx new file mode 100644 index 000000000..3378a33a0 --- /dev/null +++ b/web/react/components/suggestion/search_suggestion_list.jsx @@ -0,0 +1,86 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; +import SuggestionList from './suggestion_list.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +export default class SearchSuggestionList extends SuggestionList { + componentDidUpdate(prevProps, prevState) { + if (this.state.items.length > 0 && prevState.items.length === 0) { + this.getContent().perfectScrollbar(); + } + } + + getContent() { + return $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content'); + } + + renderChannelDivider(type) { + let text; + if (type === Constants.OPEN_CHANNEL) { + text = 'Public ' + Utils.getChannelTerm(type) + 's'; + } else { + text = 'Private ' + Utils.getChannelTerm(type) + 's'; + } + + return ( + <div + key={type + '-divider'} + className='search-autocomplete__divider' + > + <span>{text}</span> + </div> + ); + } + + render() { + if (this.state.items.length === 0) { + return null; + } + + const items = []; + for (let i = 0; i < this.state.items.length; i++) { + const item = this.state.items[i]; + const term = this.state.terms[i]; + const isSelection = term === this.state.selection; + + // ReactComponent names need to be upper case when used in JSX + const Component = this.state.components[i]; + + // temporary hack to add dividers between public and private channels in the search suggestion list + if (i === 0 || item.type !== this.state.items[i - 1].type) { + if (item.type === Constants.OPEN_CHANNEL) { + items.push(this.renderChannelDivider(Constants.OPEN_CHANNEL)); + } else if (item.type === Constants.PRIVATE_CHANNEL) { + items.push(this.renderChannelDivider(Constants.PRIVATE_CHANNEL)); + } + } + + items.push( + <Component + key={term} + ref={term} + item={item} + isSelection={isSelection} + onClick={this.handleItemClick.bind(this, term)} + /> + ); + } + + return ( + <ReactBootstrap.Popover + ref='popover' + id='search-autocomplete__popover' + className='search-help-popover autocomplete visible' + placement='bottom' + > + {items} + </ReactBootstrap.Popover> + ); + } +} + +SearchSuggestionList.propTypes = { + ...SuggestionList.propTypes +}; diff --git a/web/react/components/suggestion/search_user_provider.jsx b/web/react/components/suggestion/search_user_provider.jsx new file mode 100644 index 000000000..cf2953937 --- /dev/null +++ b/web/react/components/suggestion/search_user_provider.jsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; + +class SearchUserSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <img + className='profile-img rounded' + src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at} + /> + {item.username} + </div> + ); + } +} + +SearchUserSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchUserProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext); + if (captured) { + const usernamePrefix = captured[1]; + + const users = UserStore.getProfiles(); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix)) { + filtered.push(user); + } + } + + filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); + + const usernames = filtered.map((user) => user.username); + + SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix); + SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion); + } + } +} diff --git a/web/react/components/suggestion/suggestion_box.jsx b/web/react/components/suggestion/suggestion_box.jsx new file mode 100644 index 000000000..57a33c24a --- /dev/null +++ b/web/react/components/suggestion/suggestion_box.jsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; +import * as EventHelpers from '../../dispatcher/event_helpers.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +const KeyCodes = Constants.KeyCodes; + +export default class SuggestionBox extends React.Component { + constructor(props) { + super(props); + + this.handleDocumentClick = this.handleDocumentClick.bind(this); + + this.handleChange = this.handleChange.bind(this); + this.handleCompleteWord = this.handleCompleteWord.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handlePretextChanged = this.handlePretextChanged.bind(this); + + this.suggestionId = Utils.generateId(); + } + + componentDidMount() { + SuggestionStore.registerSuggestionBox(this.suggestionId); + $(document).on('click', this.handleDocumentClick); + + SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.addPretextChangedListener(this.suggestionId, this.handlePretextChanged); + } + + componentWillUnmount() { + SuggestionStore.removeCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.removePretextChangedListener(this.suggestionId, this.handlePretextChanged); + + SuggestionStore.unregisterSuggestionBox(this.suggestionId); + $(document).off('click', this.handleDocumentClick); + } + + getTextbox() { + // this is to support old code that looks at the input/textarea DOM nodes + return ReactDOM.findDOMNode(this.refs.textbox); + } + + handleDocumentClick(e) { + const container = $(ReactDOM.findDOMNode(this)); + if (!(container.is(e.target) || container.has(e.target).length > 0)) { + // we can't just use blur for this because it fires and hides the children before + // their click handlers can be called + EventHelpers.emitClearSuggestions(this.suggestionId); + } + } + + handleChange(e) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + const pretext = textbox.value.substring(0, caret); + + EventHelpers.emitSuggestionPretextChanged(this.suggestionId, pretext); + + if (this.props.onUserInput) { + this.props.onUserInput(textbox.value); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + } + + handleCompleteWord(term) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + + const text = this.props.value; + const prefix = text.substring(0, caret - SuggestionStore.getMatchedPretext(this.suggestionId).length); + const suffix = text.substring(caret); + + if (this.props.onUserInput) { + this.props.onUserInput(prefix + term + ' ' + suffix); + } + + // set the caret position after the next rendering + window.requestAnimationFrame(() => { + Utils.setCaretPosition(textbox, prefix.length + term.length + 1); + }); + } + + handleKeyDown(e) { + if (SuggestionStore.hasSuggestions(this.suggestionId)) { + if (e.which === KeyCodes.UP) { + EventHelpers.emitSelectPreviousSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.DOWN) { + EventHelpers.emitSelectNextSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.TAB) { + EventHelpers.emitCompleteWordSuggestion(this.suggestionId); + e.preventDefault(); + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + handlePretextChanged(pretext) { + for (const provider of this.props.providers) { + provider.handlePretextChanged(this.suggestionId, pretext); + } + } + + render() { + const newProps = Object.assign({}, this.props, { + onChange: this.handleChange, + onKeyDown: this.handleKeyDown + }); + + let textbox = null; + if (this.props.type === 'input') { + textbox = ( + <input + ref='textbox' + type='text' + {...newProps} + /> + ); + } else if (this.props.type === 'textarea') { + textbox = ( + <textarea + ref='textbox' + {...newProps} + /> + ); + } + + const SuggestionListComponent = this.props.listComponent; + + return ( + <div> + {textbox} + <SuggestionListComponent suggestionId={this.suggestionId} /> + </div> + ); + } +} + +SuggestionBox.defaultProps = { + type: 'input' +}; + +SuggestionBox.propTypes = { + listComponent: React.PropTypes.func.isRequired, + type: React.PropTypes.oneOf(['input', 'textarea']).isRequired, + value: React.PropTypes.string.isRequired, + onUserInput: React.PropTypes.func, + providers: React.PropTypes.arrayOf(React.PropTypes.object), + + // explicitly name any input event handlers we override and need to manually call + onChange: React.PropTypes.func, + onKeyDown: React.PropTypes.func +}; diff --git a/web/react/components/suggestion/suggestion_list.jsx b/web/react/components/suggestion/suggestion_list.jsx new file mode 100644 index 000000000..e3ccd0f08 --- /dev/null +++ b/web/react/components/suggestion/suggestion_list.jsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as EventHelpers from '../../dispatcher/event_helpers.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +export default class SuggestionList extends React.Component { + constructor(props) { + super(props); + + this.getContent = this.getContent.bind(this); + + this.handleItemClick = this.handleItemClick.bind(this); + this.handleSuggestionsChanged = this.handleSuggestionsChanged.bind(this); + + this.scrollToItem = this.scrollToItem.bind(this); + + this.state = { + items: [], + terms: [], + components: [], + selection: '' + }; + } + + componentDidMount() { + SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + componentWillUnmount() { + SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + getContent() { + return $(ReactDOM.findDOMNode(this.refs.content)); + } + + handleItemClick(term, e) { + EventHelpers.emitCompleteWordSuggestion(this.props.suggestionId, term); + + e.preventDefault(); + } + + handleSuggestionsChanged() { + const selection = SuggestionStore.getSelection(this.props.suggestionId); + + this.setState({ + items: SuggestionStore.getItems(this.props.suggestionId), + terms: SuggestionStore.getTerms(this.props.suggestionId), + components: SuggestionStore.getComponents(this.props.suggestionId), + selection + }); + + if (selection) { + window.requestAnimationFrame(() => this.scrollToItem(this.state.selection)); + } + } + + scrollToItem(term) { + const content = this.getContent(); + const visibleContentHeight = content[0].clientHeight; + const actualContentHeight = content[0].scrollHeight; + + if (visibleContentHeight < actualContentHeight) { + const contentTop = content.scrollTop(); + const contentTopPadding = parseInt(content.css('padding-top'), 10); + const contentBottomPadding = parseInt(content.css('padding-top'), 10); + + const item = $(ReactDOM.findDOMNode(this.refs[term])); + const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); + const itemBottomMargin = parseInt(item.css('margin-bottom'), 10) + parseInt(item.css('padding-bottom'), 10); + const itemBottom = item[0].offsetTop + item.height() + itemBottomMargin; + + if (itemTop - contentTopPadding < contentTop) { + // the item is off the top of the visible space + content.scrollTop(itemTop - contentTopPadding); + } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { + // the item has gone off the bottom of the visible space + content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); + } + } + } + + render() { + if (this.state.items.length === 0) { + return null; + } + + const items = []; + for (let i = 0; i < this.state.items.length; i++) { + const item = this.state.items[i]; + const term = this.state.terms[i]; + const isSelection = term === this.state.selection; + + // ReactComponent names need to be upper case when used in JSX + const Component = this.state.components[i]; + + items.push( + <Component + key={term} + ref={term} + item={item} + term={term} + isSelection={isSelection} + onClick={this.handleItemClick.bind(this, term)} + /> + ); + } + + return ( + <div className='suggestion-list suggestion-list--top'> + <div + ref='content' + className='suggestion-content suggestion-content--top' + > + {items} + </div> + </div> + ); + } +} + +SuggestionList.propTypes = { + suggestionId: React.PropTypes.string.isRequired +}; diff --git a/web/react/components/team_export_tab.jsx b/web/react/components/team_export_tab.jsx index e15e3a372..14df7fffc 100644 --- a/web/react/components/team_export_tab.jsx +++ b/web/react/components/team_export_tab.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); +import * as Client from '../utils/client.jsx'; export default class TeamExportTab extends React.Component { constructor(props) { diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx index 587ef5ec2..dc615f2e8 100644 --- a/web/react/components/team_general_tab.jsx +++ b/web/react/components/team_general_tab.jsx @@ -1,17 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const SettingItemMin = require('./setting_item_min.jsx'); -const SettingItemMax = require('./setting_item_max.jsx'); +import SettingItemMin from './setting_item_min.jsx'; +import SettingItemMax from './setting_item_max.jsx'; -const Client = require('../utils/client.jsx'); -const Utils = require('../utils/utils.jsx'); -const TeamStore = require('../stores/team_store.jsx'); +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; +import TeamStore from '../stores/team_store.jsx'; export default class GeneralTab extends React.Component { constructor(props) { super(props); + this.updateSection = this.updateSection.bind(this); this.handleNameSubmit = this.handleNameSubmit.bind(this); this.handleInviteIdSubmit = this.handleInviteIdSubmit.bind(this); this.handleOpenInviteSubmit = this.handleOpenInviteSubmit.bind(this); @@ -27,11 +28,22 @@ export default class GeneralTab extends React.Component { this.handleTeamListingRadio = this.handleTeamListingRadio.bind(this); this.handleGenerateInviteId = this.handleGenerateInviteId.bind(this); - this.state = { - name: props.team.display_name, - invite_id: props.team.invite_id, - allow_open_invite: props.team.allow_open_invite, - allow_team_listing: props.team.allow_team_listing, + this.state = this.setupInitialState(props); + } + + updateSection(section) { + this.setState(this.setupInitialState(this.props)); + this.props.updateSection(section); + } + + setupInitialState(props) { + const team = props.team; + + return { + name: team.display_name, + invite_id: team.invite_id, + allow_open_invite: team.allow_open_invite, + allow_team_listing: team.allow_team_listing, serverError: '', clientError: '' }; @@ -71,7 +83,7 @@ export default class GeneralTab extends React.Component { (team) => { TeamStore.saveTeam(team); TeamStore.emitChange(); - this.props.updateSection(''); + this.updateSection(''); }, (err) => { state.serverError = err.message; @@ -91,7 +103,7 @@ export default class GeneralTab extends React.Component { (team) => { TeamStore.saveTeam(team); TeamStore.emitChange(); - this.props.updateSection(''); + this.updateSection(''); }, (err) => { state.serverError = err.message; @@ -129,7 +141,7 @@ export default class GeneralTab extends React.Component { (team) => { TeamStore.saveTeam(team); TeamStore.emitChange(); - this.props.updateSection(''); + this.updateSection(''); }, (err) => { state.serverError = err.message; @@ -164,7 +176,7 @@ export default class GeneralTab extends React.Component { (team) => { TeamStore.saveTeam(team); TeamStore.emitChange(); - this.props.updateSection(''); + this.updateSection(''); }, (err) => { state.serverError = err.message; @@ -180,8 +192,7 @@ export default class GeneralTab extends React.Component { } handleClose() { - this.setState({clientError: '', serverError: ''}); - this.props.updateSection(''); + this.updateSection(''); } componentDidMount() { @@ -195,36 +206,36 @@ export default class GeneralTab extends React.Component { onUpdateNameSection(e) { e.preventDefault(); if (this.props.activeSection === 'name') { - this.props.updateSection(''); + this.updateSection(''); } else { - this.props.updateSection('name'); + this.updateSection('name'); } } onUpdateInviteIdSection(e) { e.preventDefault(); if (this.props.activeSection === 'invite_id') { - this.props.updateSection(''); + this.updateSection(''); } else { - this.props.updateSection('invite_id'); + this.updateSection('invite_id'); } } onUpdateOpenInviteSection(e) { e.preventDefault(); if (this.props.activeSection === 'open_invite') { - this.props.updateSection(''); + this.updateSection(''); } else { - this.props.updateSection('open_invite'); + this.updateSection('open_invite'); } } onUpdateTeamListingSection(e) { e.preventDefault(); if (this.props.activeSection === 'team_listing') { - this.props.updateSection(''); + this.updateSection(''); } else { - this.props.updateSection('team_listing'); + this.updateSection('team_listing'); } } @@ -248,44 +259,59 @@ export default class GeneralTab extends React.Component { serverError = this.state.serverError; } + const enableTeamListing = global.window.mm_config.EnableTeamListing === 'true'; + let teamListingSection; if (this.props.activeSection === 'team_listing') { - const inputs = [ - <div key='userTeamListingOptions'> - <div className='radio'> - <label> - <input - name='userTeamListingOptions' - type='radio' - defaultChecked={this.state.allow_team_listing} - onChange={this.handleTeamListingRadio.bind(this, true)} - /> - {'Yes'} - </label> - <br/> + const inputs = []; + let submitHandle = null; + + if (enableTeamListing) { + submitHandle = this.handleTeamListingSubmit; + + inputs.push( + <div key='userTeamListingOptions'> + <div className='radio'> + <label> + <input + name='userTeamListingOptions' + type='radio' + defaultChecked={this.state.allow_team_listing} + onChange={this.handleTeamListingRadio.bind(this, true)} + /> + {'Yes'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + ref='teamListingRadioNo' + name='userTeamListingOptions' + type='radio' + defaultChecked={!this.state.allow_team_listing} + onChange={this.handleTeamListingRadio.bind(this, false)} + /> + {'No'} + </label> + <br/> + </div> + <div><br/>{'Including this team will display the team name from the Team Directory section of the Home Page, and provide a link to the sign-in page.'}</div> </div> - <div className='radio'> - <label> - <input - ref='teamListingRadioNo' - name='userTeamListingOptions' - type='radio' - defaultChecked={!this.state.allow_team_listing} - onChange={this.handleTeamListingRadio.bind(this, false)} - /> - {'No'} - </label> - <br/> + ); + } else { + inputs.push( + <div key='userTeamListingOptions'> + <div><br/>{'Contact your system administrator to turn on the team directory on the system home page.'}</div> </div> - <div><br/>{'Including this team will display the team name from the Team Directory section of the Home Page, and provide a link to the sign-in page.'}</div> - </div> - ]; + ); + } teamListingSection = ( <SettingItemMax title='Include this team in the Team Directory' inputs={inputs} - submit={this.handleTeamListingSubmit} + submit={submitHandle} server_error={serverError} client_error={clientError} updateSection={this.onUpdateTeamListingSection} @@ -293,10 +319,15 @@ export default class GeneralTab extends React.Component { ); } else { let describe = ''; - if (this.state.allow_team_listing === true) { - describe = 'Yes'; + + if (enableTeamListing) { + if (this.state.allow_team_listing === true) { + describe = 'Yes'; + } else { + describe = 'No'; + } } else { - describe = 'No'; + describe = 'Team directory is turned off for this system.'; } teamListingSection = ( @@ -393,7 +424,7 @@ export default class GeneralTab extends React.Component { </div> </div> </div> - <div className='setting-list__hint'>{'When allowing open invites this code is used as part of the signup process. Changing this code will invalidate the previous open signup link.'}</div> + <div className='setting-list__hint'>{'Your Invite Code is used in the URL sent to people to join your team. Regenerating your Invite Code will invalidate the URLs in previous invitations, unless "Allow anyone to sign-up from login page" is enabled.'}</div> </div> ); @@ -437,6 +468,7 @@ export default class GeneralTab extends React.Component { <input className='form-control' type='text' + maxLength='22' onChange={this.updateName} value={this.state.name} /> @@ -452,6 +484,7 @@ export default class GeneralTab extends React.Component { server_error={serverError} client_error={clientError} updateSection={this.onUpdateNameSection} + extraInfo='Set the name of the team as it appears on your sign-in screen and at the top of the left-hand sidebar.' /> ); } else { diff --git a/web/react/components/team_import_tab.jsx b/web/react/components/team_import_tab.jsx index a80b1a472..37f8746d7 100644 --- a/web/react/components/team_import_tab.jsx +++ b/web/react/components/team_import_tab.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var SettingUpload = require('./setting_upload.jsx'); +import * as utils from '../utils/utils.jsx'; +import SettingUpload from './setting_upload.jsx'; export default class TeamImportTab extends React.Component { constructor(props) { diff --git a/web/react/components/team_members.jsx b/web/react/components/team_members.jsx deleted file mode 100644 index ac1ebf52d..000000000 --- a/web/react/components/team_members.jsx +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var MemberListTeam = require('./member_list_team.jsx'); -var utils = require('../utils/utils.jsx'); - -function getStateFromStores() { - var users = UserStore.getProfiles(); - var memberList = []; - for (var id in users) { - if (users.hasOwnProperty(id)) { - memberList.push(users[id]); - } - } - - memberList.sort(function sort(a, b) { - if (a.username < b.username) { - return -1; - } - - if (a.username > b.username) { - return 1; - } - - return 0; - }); - - return { - member_list: memberList - }; -} - -export default class TeamMembers extends React.Component { - constructor(props) { - super(props); - - this.onChange = this.onChange.bind(this); - - this.state = getStateFromStores(); - } - - componentDidMount() { - UserStore.addChangeListener(this.onChange); - - var self = this; - $(ReactDOM.findDOMNode(this.refs.modal)).on('hidden.bs.modal', function show() { - self.setState({render_members: false}); - }); - - $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', function hide() { - self.setState({render_members: true}); - }); - } - - componentWillUnmount() { - UserStore.removeChangeListener(this.onChange); - } - - onChange() { - var newState = getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } - } - - render() { - var serverError = null; - - if (this.state.server_error) { - serverError = <label className='has-error control-label'>{this.state.server_error}</label>; - } - - var renderMembers = ''; - - if (this.state.render_members) { - renderMembers = <MemberListTeam users={this.state.member_list} />; - } - - return ( - <div - className='modal fade more-modal' - ref='modal' - id='team_members' - tabIndex='-1' - role='dialog' - aria-hidden='true' - > - <div className='modal-dialog'> - <div className='modal-content'> - <div className='modal-header'> - <button - type='button' - className='close' - data-dismiss='modal' - aria-label='Close' - > - <span aria-hidden='true'>×</span> - </button> - <h4 - className='modal-title' - id='myModalLabel' - >{this.props.teamDisplayName + ' Members'}</h4> - </div> - <div - ref='modalBody' - className='modal-body' - > - <div className='team-member-list'> - {renderMembers} - </div> - {serverError} - </div> - <div className='modal-footer'> - <button - type='button' - className='btn btn-default' - data-dismiss='modal' - >Close</button> - </div> - </div> - </div> - </div> - ); - } -} - -TeamMembers.propTypes = { - teamDisplayName: React.PropTypes.string -}; diff --git a/web/react/components/team_members_modal.jsx b/web/react/components/team_members_modal.jsx new file mode 100644 index 000000000..27224c283 --- /dev/null +++ b/web/react/components/team_members_modal.jsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import MemberListTeam from './member_list_team.jsx'; +import TeamStore from '../stores/team_store.jsx'; + +const Modal = ReactBootstrap.Modal; + +export default class TeamMembersModal extends React.Component { + constructor(props) { + super(props); + + this.onShow = this.onShow.bind(this); + } + + componentDidMount() { + if (this.props.show) { + this.onShow(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.show && !prevProps.show) { + this.onShow(); + } + } + + onShow() { + if ($(window).width() > 768) { + $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); + } + } + + render() { + const team = TeamStore.getCurrent(); + + return ( + <Modal + dialogClassName='team-members-modal' + show={this.props.show} + onHide={this.props.onHide} + > + <Modal.Header closeButton={true}> + {team.display_name + ' Members'} + </Modal.Header> + <Modal.Body ref='modalBody'> + <div className='team-member-list'> + <MemberListTeam /> + </div> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.props.onHide} + > + {'Close'} + </button> + </Modal.Footer> + </Modal> + ); + } +} + +TeamMembersModal.propTypes = { + show: React.PropTypes.bool.isRequired, + onHide: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx index 09674f1ef..bbcedb5dd 100644 --- a/web/react/components/team_settings.jsx +++ b/web/react/components/team_settings.jsx @@ -1,11 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var TeamStore = require('../stores/team_store.jsx'); -var ImportTab = require('./team_import_tab.jsx'); -var ExportTab = require('./team_export_tab.jsx'); -var GeneralTab = require('./team_general_tab.jsx'); -var Utils = require('../utils/utils.jsx'); +import TeamStore from '../stores/team_store.jsx'; +import ImportTab from './team_import_tab.jsx'; +import ExportTab from './team_export_tab.jsx'; +import GeneralTab from './team_general_tab.jsx'; +import * as Utils from '../utils/utils.jsx'; export default class TeamSettings extends React.Component { constructor(props) { @@ -23,7 +23,7 @@ export default class TeamSettings extends React.Component { } onChange() { var team = TeamStore.getCurrent(); - if (!Utils.areStatesEqual(this.state.team, team)) { + if (!Utils.areObjectsEqual(this.state.team, team)) { this.setState({team}); } } diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index 4d47db2a8..dbdbde958 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const SettingsSidebar = require('./settings_sidebar.jsx'); -const TeamSettings = require('./team_settings.jsx'); +import SettingsSidebar from './settings_sidebar.jsx'; +import TeamSettings from './team_settings.jsx'; export default class TeamSettingsModal extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_display_name_page.jsx b/web/react/components/team_signup_display_name_page.jsx index 2005ecc31..f4d5ea162 100644 --- a/web/react/components/team_signup_display_name_page.jsx +++ b/web/react/components/team_signup_display_name_page.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); +import * as utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; export default class TeamSignupDisplayNamePage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_email_item.jsx b/web/react/components/team_signup_email_item.jsx index 1d2b24ed7..59c4771d7 100644 --- a/web/react/components/team_signup_email_item.jsx +++ b/web/react/components/team_signup_email_item.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; export default class TeamSignupEmailItem extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_password_page.jsx b/web/react/components/team_signup_password_page.jsx index 67fd686bc..378c7fe2c 100644 --- a/web/react/components/team_signup_password_page.jsx +++ b/web/react/components/team_signup_password_page.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Client = require('../utils/client.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); +import * as Client from '../utils/client.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; +import UserStore from '../stores/user_store.jsx'; export default class TeamSignupPasswordPage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_send_invites_page.jsx b/web/react/components/team_signup_send_invites_page.jsx index 7b4db8fae..a580623e4 100644 --- a/web/react/components/team_signup_send_invites_page.jsx +++ b/web/react/components/team_signup_send_invites_page.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var EmailItem = require('./team_signup_email_item.jsx'); -var Client = require('../utils/client.jsx'); +import EmailItem from './team_signup_email_item.jsx'; +import * as Client from '../utils/client.jsx'; export default class TeamSignupSendInvitesPage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx index 8972fda1a..30459fc67 100644 --- a/web/react/components/team_signup_url_page.jsx +++ b/web/react/components/team_signup_url_page.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); -const Constants = require('../utils/constants.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; export default class TeamSignupUrlPage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_username_page.jsx b/web/react/components/team_signup_username_page.jsx index d8d0dbf2c..de239f169 100644 --- a/web/react/components/team_signup_username_page.jsx +++ b/web/react/components/team_signup_username_page.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var Client = require('../utils/client.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; export default class TeamSignupUsernamePage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_welcome_page.jsx b/web/react/components/team_signup_welcome_page.jsx index 9448413ce..aa91a1329 100644 --- a/web/react/components/team_signup_welcome_page.jsx +++ b/web/react/components/team_signup_welcome_page.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var Client = require('../utils/client.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; export default class TeamSignupWelcomePage extends React.Component { constructor(props) { diff --git a/web/react/components/team_signup_with_email.jsx b/web/react/components/team_signup_with_email.jsx index 021713f04..4150a0013 100644 --- a/web/react/components/team_signup_with_email.jsx +++ b/web/react/components/team_signup_with_email.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('../utils/utils.jsx'); -const Client = require('../utils/client.jsx'); +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; export default class EmailSignUpPage extends React.Component { constructor() { @@ -14,18 +14,19 @@ export default class EmailSignUpPage extends React.Component { } handleSubmit(e) { e.preventDefault(); - var team = {}; - var state = {serverError: ''}; + const team = {}; + const state = {serverError: null}; + let isValid = true; team.email = ReactDOM.findDOMNode(this.refs.email).value.trim().toLowerCase(); if (!team.email || !Utils.isEmail(team.email)) { state.emailError = 'Please enter a valid email address'; - state.inValid = true; + isValid = false; } else { - state.emailError = ''; + state.emailError = null; } - if (state.inValid) { + if (!isValid) { this.setState(state); return; } @@ -45,11 +46,16 @@ export default class EmailSignUpPage extends React.Component { ); } render() { - var serverError = null; + let serverError = null; if (this.state.serverError) { serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; } + let emailError = null; + if (this.state.emailError) { + emailError = <div className='form-group has-error'><label className='control-label'>{this.state.emailError}</label></div>; + } + return ( <form role='form' @@ -65,6 +71,7 @@ export default class EmailSignUpPage extends React.Component { maxLength='128' spellCheck='false' /> + {emailError} </div> <div className='form-group'> <button diff --git a/web/react/components/team_signup_with_sso.jsx b/web/react/components/team_signup_with_sso.jsx index a0ccdf2c7..e3f16efb0 100644 --- a/web/react/components/team_signup_with_sso.jsx +++ b/web/react/components/team_signup_with_sso.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); -var client = require('../utils/client.jsx'); -var Constants = require('../utils/constants.jsx'); +import * as utils from '../utils/utils.jsx'; +import * as client from '../utils/client.jsx'; +import Constants from '../utils/constants.jsx'; export default class SSOSignUpPage extends React.Component { constructor(props) { diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index 707033d8f..b29f304ab 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -1,46 +1,39 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const SearchStore = require('../stores/search_store.jsx'); -const CommandList = require('./command_list.jsx'); -const ErrorStore = require('../stores/error_store.jsx'); - -const Utils = require('../utils/utils.jsx'); -const Constants = require('../utils/constants.jsx'); -const ActionTypes = Constants.ActionTypes; -const KeyCodes = Constants.KeyCodes; +import AtMentionProvider from './suggestion/at_mention_provider.jsx'; +import CommandProvider from './suggestion/command_provider.jsx'; +import EmoticonProvider from './suggestion/emoticon_provider.jsx'; +import SuggestionList from './suggestion/suggestion_list.jsx'; +import SuggestionBox from './suggestion/suggestion_box.jsx'; +import ErrorStore from '../stores/error_store.jsx'; + +import * as TextFormatting from '../utils/text_formatting.jsx'; +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; export default class Textbox extends React.Component { constructor(props) { super(props); this.getStateFromStores = this.getStateFromStores.bind(this); - this.onListenerChange = this.onListenerChange.bind(this); this.onRecievedError = this.onRecievedError.bind(this); - this.updateMentionTab = this.updateMentionTab.bind(this); - this.handleChange = this.handleChange.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleBackspace = this.handleBackspace.bind(this); - this.checkForNewMention = this.checkForNewMention.bind(this); - this.addMention = this.addMention.bind(this); - this.addCommand = this.addCommand.bind(this); this.resize = this.resize.bind(this); this.handleFocus = this.handleFocus.bind(this); this.handleBlur = this.handleBlur.bind(this); - this.handlePaste = this.handlePaste.bind(this); + this.showPreview = this.showPreview.bind(this); this.state = { - mentionText: '-1', - mentions: [], connection: '' }; - this.caret = -1; - this.addedMention = false; - this.doProcessMentions = false; - this.mentions = []; + this.suggestionProviders = [new AtMentionProvider(), new EmoticonProvider()]; + if (props.supportsCommands) { + this.suggestionProviders.push(new CommandProvider()); + } } getStateFromStores() { @@ -54,24 +47,15 @@ export default class Textbox extends React.Component { } componentDidMount() { - SearchStore.addAddMentionListener(this.onListenerChange); ErrorStore.addChangeListener(this.onRecievedError); this.resize(); - this.updateMentionTab(null); } componentWillUnmount() { - SearchStore.removeAddMentionListener(this.onListenerChange); ErrorStore.removeChangeListener(this.onRecievedError); } - onListenerChange(id, username) { - if (id === this.props.id) { - this.addMention(username); - } - } - onRecievedError() { const errorState = ErrorStore.getLastError(); @@ -83,157 +67,21 @@ export default class Textbox extends React.Component { } componentDidUpdate() { - if (this.caret >= 0) { - Utils.setCaretPosition(ReactDOM.findDOMNode(this.refs.message), this.caret); - this.caret = -1; - } - if (this.doProcessMentions) { - this.updateMentionTab(null); - this.doProcessMentions = false; - } this.resize(); } - componentWillReceiveProps(nextProps) { - if (!this.addedMention) { - this.checkForNewMention(nextProps.messageText); - } - const text = ReactDOM.findDOMNode(this.refs.message).value; - if (nextProps.channelId !== this.props.channelId || nextProps.messageText !== text) { - this.doProcessMentions = true; - } - this.addedMention = false; - this.refs.commands.getSuggestedCommands(nextProps.messageText); - } - - updateMentionTab(mentionText) { - // using setTimeout so dispatch isn't called during an in progress dispatch - setTimeout(() => { - AppDispatcher.handleViewAction({ - type: ActionTypes.RECIEVED_MENTION_DATA, - id: this.props.id, - mention_text: mentionText - }); - }, 1); - } - - handleChange() { - this.props.onUserInput(ReactDOM.findDOMNode(this.refs.message).value); - } - handleKeyPress(e) { - const text = ReactDOM.findDOMNode(this.refs.message).value; - - if (!this.refs.commands.isEmpty() && text.indexOf('/') === 0 && e.which === 13) { - this.refs.commands.addFirstCommand(); - e.preventDefault(); - return; - } - - if (!this.doProcessMentions) { - const caret = Utils.getCaretPosition(ReactDOM.findDOMNode(this.refs.message)); - const preText = text.substring(0, caret); - const lastSpace = preText.lastIndexOf(' '); - const lastAt = preText.lastIndexOf('@'); - - if (caret > lastAt && lastSpace < lastAt) { - this.doProcessMentions = true; - } - } - this.props.onKeyPress(e); } handleKeyDown(e) { - if (Utils.getSelectedText(ReactDOM.findDOMNode(this.refs.message)) !== '') { - this.doProcessMentions = true; - } - - if (e.keyCode === KeyCodes.BACKSPACE) { - this.handleBackspace(e); - } else if (this.props.onKeyDown) { + if (this.props.onKeyDown) { this.props.onKeyDown(e); } } - handleBackspace() { - const text = ReactDOM.findDOMNode(this.refs.message).value; - if (text.indexOf('/') === 0) { - this.refs.commands.getSuggestedCommands(text.substring(0, text.length - 1)); - } - - if (this.doProcessMentions) { - return; - } - - const caret = Utils.getCaretPosition(ReactDOM.findDOMNode(this.refs.message)); - const preText = text.substring(0, caret); - const lastSpace = preText.lastIndexOf(' '); - const lastAt = preText.lastIndexOf('@'); - - if (caret > lastAt && (lastSpace > lastAt || lastSpace === -1)) { - this.doProcessMentions = true; - } - } - - checkForNewMention(text) { - const caret = Utils.getCaretPosition(ReactDOM.findDOMNode(this.refs.message)); - - const preText = text.substring(0, caret); - - const atIndex = preText.lastIndexOf('@'); - - // The @ character not typed, so nothing to do. - if (atIndex === -1) { - this.updateMentionTab('-1'); - return; - } - - const lastCharSpace = preText.lastIndexOf(String.fromCharCode(160)); - const lastSpace = preText.lastIndexOf(' '); - - // If there is a space after the last @, nothing to do. - if (lastSpace > atIndex || lastCharSpace > atIndex) { - this.updateMentionTab('-1'); - return; - } - - // Get the name typed so far. - const name = preText.substring(atIndex + 1, preText.length).toLowerCase(); - this.updateMentionTab(name); - } - - addMention(name) { - const caret = Utils.getCaretPosition(ReactDOM.findDOMNode(this.refs.message)); - - const text = this.props.messageText; - - const preText = text.substring(0, caret); - - const atIndex = preText.lastIndexOf('@'); - - // The @ character not typed, so nothing to do. - if (atIndex === -1) { - return; - } - - const prefix = text.substring(0, atIndex); - const suffix = text.substring(caret, text.length); - this.caret = prefix.length + name.length + 2; - this.addedMention = true; - this.doProcessMentions = true; - - this.props.onUserInput(`${prefix}@${name} ${suffix}`); - } - - addCommand(cmd) { - const elm = ReactDOM.findDOMNode(this.refs.message); - elm.value = cmd; - this.handleChange(); - } - resize() { - const e = ReactDOM.findDOMNode(this.refs.message); + const e = this.refs.message.getTextbox(); const w = ReactDOM.findDOMNode(this.refs.wrapper); const prevHeight = $(e).height(); @@ -250,10 +98,16 @@ export default class Textbox extends React.Component { $(e).css({height: 'auto', 'overflow-y': 'hidden'}).height(e.scrollHeight - mod); $(w).css({height: 'auto'}).height(e.scrollHeight + 2); $(w).closest('.post-body__cell').removeClass('scroll'); + if (this.state.preview) { + $(ReactDOM.findDOMNode(this.refs.preview)).css({height: 'auto', 'overflow-y': 'auto'}).height(e.scrollHeight - mod); + } } else { - $(e).css({height: 'auto', 'overflow-y': 'scroll'}).height(167); - $(w).css({height: 'auto'}).height(167); + $(e).css({height: 'auto', 'overflow-y': 'scroll'}).height(167 - mod); + $(w).css({height: 'auto'}).height(163); $(w).closest('.post-body__cell').addClass('scroll'); + if (this.state.preview) { + $(ReactDOM.findDOMNode(this.refs.preview)).css({height: 'auto', 'overflow-y': 'scroll'}).height(163); + } } if (prevHeight !== $(e).height() && this.props.onHeightChange) { @@ -262,38 +116,58 @@ export default class Textbox extends React.Component { } handleFocus() { - const elm = ReactDOM.findDOMNode(this.refs.message); + const elm = this.refs.message.getTextbox(); if (elm.title === elm.value) { elm.value = ''; } } handleBlur() { - const elm = ReactDOM.findDOMNode(this.refs.message); + const elm = this.refs.message.getTextbox(); if (elm.value === '') { elm.value = elm.title; } } - handlePaste() { - this.doProcessMentions = true; + showPreview(e) { + e.preventDefault(); + e.target.blur(); + this.setState({preview: !this.state.preview}); + this.resize(); + } + + showHelp(e) { + e.preventDefault(); + e.target.blur(); + + global.window.open('/docs/Messaging'); } render() { + let previewLink = null; + if (Utils.isFeatureEnabled(PreReleaseFeatures.MARKDOWN_PREVIEW)) { + const previewLinkVisible = this.props.messageText.length > 0; + previewLink = ( + <a + style={{visibility: previewLinkVisible ? 'visible' : 'hidden'}} + onClick={this.showPreview} + className='textbox-preview-link' + > + {this.state.preview ? 'Edit message' : 'Preview'} + </a> + ); + } + return ( <div ref='wrapper' className='textarea-wrapper' > - <CommandList - ref='commands' - addCommand={this.addCommand} - channelId={this.props.channelId} - /> - <textarea + <SuggestionBox id={this.props.id} ref='message' className={`form-control custom-textarea ${this.state.connection}`} + type='textarea' spellCheck='true' autoComplete='off' autoCorrect='off' @@ -301,19 +175,39 @@ export default class Textbox extends React.Component { maxLength={Constants.MAX_POST_LEN} placeholder={this.props.createMessage} value={this.props.messageText} - onInput={this.handleChange} - onChange={this.handleChange} + onUserInput={this.props.onUserInput} onKeyPress={this.handleKeyPress} onKeyDown={this.handleKeyDown} onFocus={this.handleFocus} onBlur={this.handleBlur} onPaste={this.handlePaste} + style={{visibility: this.state.preview ? 'hidden' : 'visible'}} + listComponent={SuggestionList} + providers={this.suggestionProviders} /> + <div + ref='preview' + className='form-control custom-textarea textbox-preview-area' + style={{display: this.state.preview ? 'block' : 'none'}} + dangerouslySetInnerHTML={{__html: this.state.preview ? TextFormatting.formatText(this.props.messageText) : ''}} + > + </div> + {previewLink} + <a + onClick={this.showHelp} + className='textbox-help-link' + > + {'Help'} + </a> </div> ); } } +Textbox.defaultProps = { + supportsCommands: true +}; + Textbox.propTypes = { id: React.PropTypes.string.isRequired, channelId: React.PropTypes.string, @@ -322,5 +216,6 @@ Textbox.propTypes = { onKeyPress: React.PropTypes.func.isRequired, onHeightChange: React.PropTypes.func, createMessage: React.PropTypes.string.isRequired, - onKeyDown: React.PropTypes.func + onKeyDown: React.PropTypes.func, + supportsCommands: React.PropTypes.bool.isRequired }; diff --git a/web/react/components/time_since.jsx b/web/react/components/time_since.jsx index c37739b9c..cffff6ee7 100644 --- a/web/react/components/time_since.jsx +++ b/web/react/components/time_since.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); +import * as Utils from '../utils/utils.jsx'; var Tooltip = ReactBootstrap.Tooltip; var OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -34,7 +34,7 @@ export default class TimeSince extends React.Component { placement='top' overlay={tooltip} > - <time className='post-profile-time'> + <time className='post__time'> {Utils.displayDateTime(this.props.eventTime)} </time> </OverlayTrigger> diff --git a/web/react/components/toggle_modal_button.jsx b/web/react/components/toggle_modal_button.jsx new file mode 100644 index 000000000..ce8ff3f60 --- /dev/null +++ b/web/react/components/toggle_modal_button.jsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default class ModalToggleButton extends React.Component { + constructor(props) { + super(props); + + this.show = this.show.bind(this); + this.hide = this.hide.bind(this); + + this.state = { + show: false + }; + } + + show() { + this.setState({show: true}); + } + + hide() { + this.setState({show: false}); + } + + render() { + const {children, dialogType, dialogProps, onClick, ...props} = this.props; // eslint-disable-line no-redeclare + + // allow callers to provide an onClick which will be called before the modal is shown + let clickHandler = this.show; + if (onClick) { + clickHandler = () => { + onClick(); + + this.show(); + }; + } + + // this assumes that all modals will have a show property and an onHide event + const dialog = React.createElement(this.props.dialogType, Object.assign({}, dialogProps, { + show: this.state.show, + onHide: () => { + this.hide(); + + if (dialogProps.onHide) { + dialogProps.onHide(); + } + } + })); + + // nesting the dialog in the anchor tag looks like it shouldn't work, but it does due to how react-bootstrap + // renders modals at the top level of the DOM instead of where you specify in the virtual DOM + return ( + <a + {...props} + href='#' + onClick={clickHandler} + > + {children} + {dialog} + </a> + ); + } +} + +ModalToggleButton.propTypes = { + children: React.PropTypes.node.isRequired, + dialogType: React.PropTypes.func.isRequired, + dialogProps: React.PropTypes.object, + onClick: React.PropTypes.func +}; + +ModalToggleButton.defaultProps = { + dialogProps: {} +}; diff --git a/web/react/components/tutorial/tutorial_intro_screens.jsx b/web/react/components/tutorial/tutorial_intro_screens.jsx index 66ca556c6..7ab1e5512 100644 --- a/web/react/components/tutorial/tutorial_intro_screens.jsx +++ b/web/react/components/tutorial/tutorial_intro_screens.jsx @@ -1,14 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const UserStore = require('../../stores/user_store.jsx'); -const ChannelStore = require('../../stores/channel_store.jsx'); -const TeamStore = require('../../stores/team_store.jsx'); -const PreferenceStore = require('../../stores/preference_store.jsx'); -const Utils = require('../../utils/utils.jsx'); -const AsyncClient = require('../../utils/async_client.jsx'); - -const Constants = require('../../utils/constants.jsx'); +import UserStore from '../../stores/user_store.jsx'; +import ChannelStore from '../../stores/channel_store.jsx'; +import TeamStore from '../../stores/team_store.jsx'; +import PreferenceStore from '../../stores/preference_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +import Constants from '../../utils/constants.jsx'; const Preferences = Constants.Preferences; const NUM_SCREENS = 3; @@ -41,6 +41,11 @@ export default class TutorialIntroScreens extends React.Component { componentDidMount() { $('.tutorials__scroll').perfectScrollbar(); } + skipTutorial(e) { + e.preventDefault(); + const preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), '999'); + AsyncClient.savePreferences([preference]); + } createScreen() { switch (this.state.currentScreen) { case 0: @@ -107,23 +112,30 @@ export default class TutorialIntroScreens extends React.Component { const circles = this.createCircles(); - return ( - <div> - <h3>{'You’re all set'}</h3> - <p> - {inviteModalLink} - {' when you’re ready.'} - </p> + let supportInfo = null; + if (global.window.mm_config.SupportEmail) { + supportInfo = ( <p> {'Need anything, just email us at '} <a - href='mailto:feedback@mattermost.com' + href={'mailto:' + global.window.mm_config.SupportEmail} target='_blank' > - {'feedback@mattermost.com'} + {global.window.mm_config.SupportEmail} </a> {'.'} </p> + ); + } + + return ( + <div> + <h3>{'You’re all set'}</h3> + <p> + {inviteModalLink} + {' when you’re ready.'} + </p> + {supportInfo} {'Click “Next” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.'} {circles} </div> @@ -176,6 +188,13 @@ export default class TutorialIntroScreens extends React.Component { > {'Next'} </button> + <a + className='tutorial-skip' + href='#' + onClick={this.skipTutorial} + > + {'Skip tutorial'} + </a> </div> </div> </div> diff --git a/web/react/components/tutorial/tutorial_tip.jsx b/web/react/components/tutorial/tutorial_tip.jsx index 75d73e920..d7c67cc9c 100644 --- a/web/react/components/tutorial/tutorial_tip.jsx +++ b/web/react/components/tutorial/tutorial_tip.jsx @@ -1,11 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const UserStore = require('../../stores/user_store.jsx'); -const PreferenceStore = require('../../stores/preference_store.jsx'); -const AsyncClient = require('../../utils/async_client.jsx'); +import UserStore from '../../stores/user_store.jsx'; +import PreferenceStore from '../../stores/preference_store.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; -const Constants = require('../../utils/constants.jsx'); +import Constants from '../../utils/constants.jsx'; const Preferences = Constants.Preferences; const Overlay = ReactBootstrap.Overlay; @@ -51,21 +51,22 @@ export default class TutorialTip extends React.Component { const dots = []; if (this.props.screens.length > 1) { for (let i = 0; i < this.props.screens.length; i++) { + let className = 'circle'; if (i === this.state.currentScreen) { - dots.push( - <div - className='circle active' - key={'dotactive' + i} - /> - ); - } else { - dots.push( - <div - className='circle' - key={'dotinactive' + i} - /> - ); + className += ' active'; } + + dots.push( + <a + href='#' + key={'dotactive' + i} + className={className} + onClick={(e) => { //eslint-disable-line no-loop-func + e.preventDefault(); + this.setState({currentScreen: i}); + }} + /> + ); } } diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx index eb0a8f0ca..385cd0f52 100644 --- a/web/react/components/user_profile.jsx +++ b/web/react/components/user_profile.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../utils/utils.jsx'); -var UserStore = require('../stores/user_store.jsx'); +import * as Utils from '../utils/utils.jsx'; +import UserStore from '../stores/user_store.jsx'; var Popover = ReactBootstrap.Popover; var OverlayTrigger = ReactBootstrap.OverlayTrigger; @@ -29,7 +29,7 @@ export default class UserProfile extends React.Component { return {profile: {id: '0', username: '...'}}; } - return {profile: profile}; + return {profile}; } componentDidMount() { UserStore.addChangeListener(this.onChange); @@ -43,7 +43,7 @@ export default class UserProfile extends React.Component { onChange(userId) { if (!userId || userId === this.props.userId) { var newState = this.getStateFromStores(this.props.userId); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } @@ -54,20 +54,27 @@ export default class UserProfile extends React.Component { } } render() { - var name = this.state.profile.username; + var name = Utils.displayUsername(this.state.profile.id); if (this.props.overwriteName) { name = this.props.overwriteName; + } else if (!name) { + name = '...'; } if (this.props.disablePopover) { return <div>{name}</div>; } + var profileImg = '/api/v1/users/' + this.state.profile.id + '/image?time=' + this.state.profile.update_at + '&' + Utils.getSessionIndex(); + if (this.props.overwriteImage) { + profileImg = this.props.overwriteImage; + } + var dataContent = []; dataContent.push( <img className='user-popover__image' - src={'/api/v1/users/' + this.state.profile.id + '/image?time=' + this.state.profile.update_at + '&' + Utils.getSessionIndex()} + src={profileImg} height='128' width='128' key='user-popover-image' @@ -107,7 +114,7 @@ export default class UserProfile extends React.Component { rootClose={true} overlay={ <Popover - title={this.state.profile.username} + title={name} id='user-profile-popover' > {dataContent} @@ -128,10 +135,12 @@ export default class UserProfile extends React.Component { UserProfile.defaultProps = { userId: '', overwriteName: '', + overwriteImage: '', disablePopover: false }; UserProfile.propTypes = { userId: React.PropTypes.string, overwriteName: React.PropTypes.string, + overwriteImage: React.PropTypes.string, disablePopover: React.PropTypes.bool }; diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx index 3dbed72c3..35f836adb 100644 --- a/web/react/components/user_settings/custom_theme_chooser.jsx +++ b/web/react/components/user_settings/custom_theme_chooser.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Constants = require('../../utils/constants.jsx'); +import Constants from '../../utils/constants.jsx'; export default class CustomThemeChooser extends React.Component { constructor(props) { @@ -14,7 +14,10 @@ export default class CustomThemeChooser extends React.Component { this.state = {}; } componentDidMount() { - $('.color-picker').colorpicker().on('changeColor', this.onPickerChange); + $('.color-picker').colorpicker({ + format: 'hex' + }); + $('.color-picker').on('changeColor', this.onPickerChange); } onPickerChange(e) { const theme = this.props.theme; diff --git a/web/react/components/user_settings/import_theme_modal.jsx b/web/react/components/user_settings/import_theme_modal.jsx index 4d594bb1b..3df9dfedf 100644 --- a/web/react/components/user_settings/import_theme_modal.jsx +++ b/web/react/components/user_settings/import_theme_modal.jsx @@ -1,14 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const ModalStore = require('../../stores/modal_store.jsx'); -const UserStore = require('../../stores/user_store.jsx'); -const Utils = require('../../utils/utils.jsx'); -const Client = require('../../utils/client.jsx'); +import ModalStore from '../../stores/modal_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import * as Client from '../../utils/client.jsx'; const Modal = ReactBootstrap.Modal; -const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx'); -const Constants = require('../../utils/constants.jsx'); +import AppDispatcher from '../../dispatcher/app_dispatcher.jsx'; +import Constants from '../../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; export default class ImportThemeModal extends React.Component { diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx index 128c011ea..9ebb55646 100644 --- a/web/react/components/user_settings/manage_incoming_hooks.jsx +++ b/web/react/components/user_settings/manage_incoming_hooks.jsx @@ -1,11 +1,11 @@ // 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 Constants = require('../../utils/constants.jsx'); -var ChannelStore = require('../../stores/channel_store.jsx'); -var LoadingScreen = require('../loading_screen.jsx'); +import * as Client from '../../utils/client.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import Constants from '../../utils/constants.jsx'; +import ChannelStore from '../../stores/channel_store.jsx'; +import LoadingScreen from '../loading_screen.jsx'; export default class ManageIncomingHooks extends React.Component { constructor() { diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx index aea886f15..ede639691 100644 --- a/web/react/components/user_settings/manage_outgoing_hooks.jsx +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -1,12 +1,12 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const LoadingScreen = require('../loading_screen.jsx'); +import LoadingScreen from '../loading_screen.jsx'; -const ChannelStore = require('../../stores/channel_store.jsx'); +import ChannelStore from '../../stores/channel_store.jsx'; -const Client = require('../../utils/client.jsx'); -const Constants = require('../../utils/constants.jsx'); +import * as Client from '../../utils/client.jsx'; +import Constants from '../../utils/constants.jsx'; export default class ManageOutgoingHooks extends React.Component { constructor() { @@ -188,7 +188,7 @@ export default class ManageOutgoingHooks extends React.Component { key={hook.id} className='webhook__item' > - <div className='padding-top x2'> + <div className='padding-top x2 webhook__url'> <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span> </div> {channelDiv} diff --git a/web/react/components/user_settings/premade_theme_chooser.jsx b/web/react/components/user_settings/premade_theme_chooser.jsx index 22cfcebcd..9889bff5c 100644 --- a/web/react/components/user_settings/premade_theme_chooser.jsx +++ b/web/react/components/user_settings/premade_theme_chooser.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Utils = require('../../utils/utils.jsx'); -var Constants = require('../../utils/constants.jsx'); +import * as Utils from '../../utils/utils.jsx'; +import Constants from '../../utils/constants.jsx'; export default class PremadeThemeChooser extends React.Component { constructor(props) { diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx index e089ce973..54d98bbde 100644 --- a/web/react/components/user_settings/user_settings.jsx +++ b/web/react/components/user_settings/user_settings.jsx @@ -1,16 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var utils = require('../../utils/utils.jsx'); -var NotificationsTab = require('./user_settings_notifications.jsx'); -var SecurityTab = require('./user_settings_security.jsx'); -var GeneralTab = require('./user_settings_general.jsx'); -var AppearanceTab = require('./user_settings_appearance.jsx'); -var DeveloperTab = require('./user_settings_developer.jsx'); -var IntegrationsTab = require('./user_settings_integrations.jsx'); -var DisplayTab = require('./user_settings_display.jsx'); -var AdvancedTab = require('./user_settings_advanced.jsx'); +import UserStore from '../../stores/user_store.jsx'; +import * as utils from '../../utils/utils.jsx'; +import NotificationsTab from './user_settings_notifications.jsx'; +import SecurityTab from './user_settings_security.jsx'; +import GeneralTab from './user_settings_general.jsx'; +import AppearanceTab from './user_settings_appearance.jsx'; +import DeveloperTab from './user_settings_developer.jsx'; +import IntegrationsTab from './user_settings_integrations.jsx'; +import DisplayTab from './user_settings_display.jsx'; +import AdvancedTab from './user_settings_advanced.jsx'; export default class UserSettings extends React.Component { constructor(props) { @@ -36,7 +36,7 @@ export default class UserSettings extends React.Component { onListenerChange() { var user = UserStore.getCurrentUser(); - if (!utils.areStatesEqual(this.state.user, user)) { + if (!utils.areObjectsEqual(this.state.user, user)) { this.setState({user}); } } diff --git a/web/react/components/user_settings/user_settings_advanced.jsx b/web/react/components/user_settings/user_settings_advanced.jsx index 2616981ba..c15936ccd 100644 --- a/web/react/components/user_settings/user_settings_advanced.jsx +++ b/web/react/components/user_settings/user_settings_advanced.jsx @@ -1,11 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Client = require('../../utils/client.jsx'); -const SettingItemMin = require('../setting_item_min.jsx'); -const SettingItemMax = require('../setting_item_max.jsx'); -const Constants = require('../../utils/constants.jsx'); -const PreferenceStore = require('../../stores/preference_store.jsx'); +import * as Client from '../../utils/client.jsx'; +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import Constants from '../../utils/constants.jsx'; +import PreferenceStore from '../../stores/preference_store.jsx'; +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; export default class AdvancedSettingsDisplay extends React.Component { constructor(props) { @@ -13,21 +14,33 @@ export default class AdvancedSettingsDisplay extends React.Component { this.updateSection = this.updateSection.bind(this); this.updateSetting = this.updateSetting.bind(this); - this.setupInitialState = this.setupInitialState.bind(this); + this.toggleFeature = this.toggleFeature.bind(this); + this.saveEnabledFeatures = this.saveEnabledFeatures.bind(this); - this.state = this.setupInitialState(); - } + const preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures); + const advancedSettings = PreferenceStore.getPreferences(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS); + const settings = { + send_on_ctrl_enter: PreferenceStore.getPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + {value: 'false'} + ).value + }; - setupInitialState() { - const sendOnCtrlEnter = PreferenceStore.getPreference( - Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, - 'send_on_ctrl_enter', - {value: 'false'} - ).value; + let enabledFeatures = 0; + advancedSettings.forEach((setting) => { + preReleaseFeaturesKeys.forEach((key) => { + const feature = PreReleaseFeatures[key]; + if (setting.name === Constants.FeatureTogglePrefix + feature.label) { + settings[setting.name] = setting.value; + if (setting.value === 'true') { + enabledFeatures++; + } + } + }); + }); - return { - settings: {send_on_ctrl_enter: sendOnCtrlEnter} - }; + this.state = {preReleaseFeatures: PreReleaseFeatures, settings, preReleaseFeaturesKeys, enabledFeatures}; } updateSetting(setting, value) { @@ -36,14 +49,45 @@ export default class AdvancedSettingsDisplay extends React.Component { this.setState(settings); } - handleSubmit(setting) { - const preference = PreferenceStore.setPreference( - Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, - setting, - this.state.settings[setting] - ); + toggleFeature(feature, checked) { + const settings = this.state.settings; + settings[Constants.FeatureTogglePrefix + feature] = String(checked); + + let enabledFeatures = 0; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0 && this.state.settings[setting] === 'true') { + enabledFeatures++; + } + }); + + this.setState({settings, enabledFeatures}); + } + + saveEnabledFeatures() { + const features = []; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0) { + features.push(setting); + } + }); + + this.handleSubmit(features); + } - Client.savePreferences([preference], + handleSubmit(settings) { + const preferences = []; + + (Array.isArray(settings) ? settings : [settings]).forEach((setting) => { + preferences.push( + PreferenceStore.setPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + setting, + String(this.state.settings[setting]) + ) + ); + }); + + Client.savePreferences(preferences, () => { PreferenceStore.emitChange(); this.updateSection(''); @@ -118,6 +162,66 @@ export default class AdvancedSettingsDisplay extends React.Component { ); } + let previewFeaturesSection; + let previewFeaturesSectionDivider; + if (this.state.preReleaseFeaturesKeys.length > 0) { + previewFeaturesSectionDivider = ( + <div className='divider-light'/> + ); + + if (this.props.activeSection === 'advancedPreviewFeatures') { + const inputs = []; + + this.state.preReleaseFeaturesKeys.forEach((key) => { + const feature = this.state.preReleaseFeatures[key]; + inputs.push( + <div key={'advancedPreviewFeatures_' + feature.label}> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.settings[Constants.FeatureTogglePrefix + feature.label] === 'true'} + onChange={(e) => { + this.toggleFeature(feature.label, e.target.checked); + }} + /> + {feature.description} + </label> + </div> + </div> + ); + }); + + inputs.push( + <div key='advancedPreviewFeatures_helptext'> + <br/> + {'Check any pre-released features you\'d like to preview. You may also need to refresh the page before the setting will take effect.'} + </div> + ); + + previewFeaturesSection = ( + <SettingItemMax + title='Preview pre-release features' + inputs={inputs} + submit={this.saveEnabledFeatures} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + previewFeaturesSection = ( + <SettingItemMin + title='Preview pre-release features' + describe={this.state.enabledFeatures + (this.state.enabledFeatures === 1 ? ' Feature ' : ' Features ') + 'enabled'} + updateSection={() => this.props.updateSection('advancedPreviewFeatures')} + /> + ); + } + } + return ( <div> <div className='modal-header'> @@ -145,6 +249,8 @@ export default class AdvancedSettingsDisplay extends React.Component { <h3 className='tab-header'>{'Advanced Settings'}</h3> <div className='divider-dark first'/> {ctrlSendSection} + {previewFeaturesSectionDivider} + {previewFeaturesSection} <div className='divider-dark'/> </div> </div> diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index d73b5f476..ad41ab771 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -1,14 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var Client = require('../../utils/client.jsx'); -var Utils = require('../../utils/utils.jsx'); +import CustomThemeChooser from './custom_theme_chooser.jsx'; +import PremadeThemeChooser from './premade_theme_chooser.jsx'; -const CustomThemeChooser = require('./custom_theme_chooser.jsx'); -const PremadeThemeChooser = require('./premade_theme_chooser.jsx'); -const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx'); -const Constants = require('../../utils/constants.jsx'); +import UserStore from '../../stores/user_store.jsx'; + +import AppDispatcher from '../../dispatcher/app_dispatcher.jsx'; +import * as Client from '../../utils/client.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +import Constants from '../../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; export default class UserSettingsAppearance extends React.Component { @@ -66,7 +68,7 @@ export default class UserSettingsAppearance extends React.Component { onChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } diff --git a/web/react/components/user_settings/user_settings_developer.jsx b/web/react/components/user_settings/user_settings_developer.jsx index e6adba1d4..01e13be57 100644 --- a/web/react/components/user_settings/user_settings_developer.jsx +++ b/web/react/components/user_settings/user_settings_developer.jsx @@ -1,18 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import * as EventHelpers from '../../dispatcher/event_helpers.jsx'; export default class DeveloperTab extends React.Component { constructor(props) { super(props); + this.register = this.register.bind(this); + this.state = {}; } register() { - $('#user_settings1').modal('hide'); - $('#register_app').modal('show'); + this.props.closeModal(); + EventHelpers.showRegisterAppModal(); } render() { var appSection; @@ -21,7 +24,10 @@ export default class DeveloperTab extends React.Component { var inputs = []; inputs.push( - <div className='form-group'> + <div + key='registerbtn' + className='form-group' + > <div className='col-sm-7'> <a className='btn btn-sm btn-primary' diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx index 43c8d33d1..c464258de 100644 --- a/web/react/components/user_settings/user_settings_display.jsx +++ b/web/react/components/user_settings/user_settings_display.jsx @@ -6,14 +6,17 @@ import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import Constants from '../../utils/constants.jsx'; import PreferenceStore from '../../stores/preference_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; function getDisplayStateFromStores() { const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'username'}); + const selectedFont = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', {value: Constants.DEFAULT_FONT}); return { militaryTime: militaryTime.value, - nameFormat: nameFormat.value + nameFormat: nameFormat.value, + selectedFont: selectedFont.value }; } @@ -24,15 +27,20 @@ export default class UserSettingsDisplay extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.handleClockRadio = this.handleClockRadio.bind(this); this.handleNameRadio = this.handleNameRadio.bind(this); + this.handleFont = this.handleFont.bind(this); this.updateSection = this.updateSection.bind(this); this.state = getDisplayStateFromStores(); + this.selectedFont = this.state.selectedFont; } handleSubmit() { const timePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); const namePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', this.state.nameFormat); + const fontPreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', this.state.selectedFont); - savePreferences([timePreference, namePreference], + this.selectedFont = this.state.selectedFont; + + savePreferences([timePreference, namePreference, fontPreference], () => { PreferenceStore.emitChange(); this.updateSection(''); @@ -48,6 +56,10 @@ export default class UserSettingsDisplay extends React.Component { handleNameRadio(nameFormat) { this.setState({nameFormat}); } + handleFont(selectedFont) { + Utils.applyFont(selectedFont); + this.setState({selectedFont}); + } updateSection(section) { this.setState(getDisplayStateFromStores()); this.props.updateSection(section); @@ -56,6 +68,8 @@ export default class UserSettingsDisplay extends React.Component { const serverError = this.state.serverError || null; let clockSection; let nameFormatSection; + let fontSection; + if (this.props.activeSection === 'clock') { const clockFormat = [false, false]; if (this.state.militaryTime === 'true') { @@ -209,6 +223,66 @@ export default class UserSettingsDisplay extends React.Component { ); } + if (this.props.activeSection === 'font') { + const options = []; + Object.keys(Constants.FONTS).forEach((fontName, idx) => { + const className = Constants.FONTS[fontName]; + options.push( + <option + key={'font_' + idx} + value={fontName} + className={className} + > + {fontName} + </option> + ); + }); + + const inputs = [ + <div key='userDisplayNameOptions'> + <div + className='dropdown' + > + <select + className='form-control' + type='text' + value={this.state.selectedFont} + onChange={(e) => this.handleFont(e.target.value)} + > + {options} + </select> + </div> + <div><br/>{'Select the font displayed in the Mattermost user interface.'}</div> + </div> + ]; + + fontSection = ( + <SettingItemMax + title='Display Font' + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={(e) => { + if (this.selectedFont !== this.state.selectedFont) { + this.handleFont(this.selectedFont); + } + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + fontSection = ( + <SettingItemMin + title='Display Font' + describe={this.state.selectedFont} + updateSection={() => { + this.props.updateSection('font'); + }} + /> + ); + } + return ( <div> <div className='modal-header'> @@ -235,6 +309,8 @@ export default class UserSettingsDisplay extends React.Component { <div className='user-settings'> <h3 className='tab-header'>{'Display Settings'}</h3> <div className='divider-dark first'/> + {fontSection} + <div className='divider-dark'/> {clockSection} <div className='divider-dark'/> {nameFormatSection} diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 1bfae6930..7c1a1297f 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -1,15 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var ErrorStore = require('../../stores/error_store.jsx'); -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); -var SettingPicture = require('../setting_picture.jsx'); -var client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var utils = require('../../utils/utils.jsx'); -var assign = require('object-assign'); +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import SettingPicture from '../setting_picture.jsx'; + +import UserStore from '../../stores/user_store.jsx'; +import ErrorStore from '../../stores/error_store.jsx'; + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import * as Utils from '../../utils/utils.jsx'; export default class UserSettingsGeneralTab extends React.Component { constructor(props) { @@ -32,17 +33,15 @@ export default class UserSettingsGeneralTab extends React.Component { this.updatePicture = this.updatePicture.bind(this); this.updateSection = this.updateSection.bind(this); - this.setupInitialState = this.setupInitialState.bind(this); - this.state = this.setupInitialState(props); } submitUsername(e) { e.preventDefault(); - var user = this.props.user; - var username = this.state.username.trim().toLowerCase(); + const user = Object.assign({}, this.props.user); + const username = this.state.username.trim().toLowerCase(); - var usernameError = utils.isValidUsername(username); + const usernameError = Utils.isValidUsername(username); if (usernameError === 'Cannot use a reserved word as a username.') { this.setState({clientError: 'This username is reserved, please choose a new one.'}); return; @@ -52,7 +51,7 @@ export default class UserSettingsGeneralTab extends React.Component { } if (user.username === username) { - this.setState({clientError: 'You must submit a new username'}); + this.updateSection(''); return; } @@ -63,11 +62,11 @@ export default class UserSettingsGeneralTab extends React.Component { submitNickname(e) { e.preventDefault(); - var user = UserStore.getCurrentUser(); - var nickname = this.state.nickname.trim(); + const user = Object.assign({}, this.props.user); + const nickname = this.state.nickname.trim(); if (user.nickname === nickname) { - this.setState({clientError: 'You must submit a new nickname'}); + this.updateSection(''); return; } @@ -78,12 +77,12 @@ export default class UserSettingsGeneralTab extends React.Component { submitName(e) { e.preventDefault(); - var user = UserStore.getCurrentUser(); - var firstName = this.state.firstName.trim(); - var lastName = this.state.lastName.trim(); + const user = Object.assign({}, this.props.user); + const firstName = this.state.firstName.trim(); + const lastName = this.state.lastName.trim(); if (user.first_name === firstName && user.last_name === lastName) { - this.setState({clientError: 'You must submit a new first or last name'}); + this.updateSection(''); return; } @@ -95,21 +94,22 @@ export default class UserSettingsGeneralTab extends React.Component { submitEmail(e) { e.preventDefault(); - var user = UserStore.getCurrentUser(); - var email = this.state.email.trim().toLowerCase(); - var confirmEmail = this.state.confirmEmail.trim().toLowerCase(); + const user = Object.assign({}, this.props.user); + const email = this.state.email.trim().toLowerCase(); + const confirmEmail = this.state.confirmEmail.trim().toLowerCase(); - if (user.email === email) { + if (email === '' || !Utils.isEmail(email)) { + this.setState({emailError: 'Please enter a valid email address.', clientError: '', serverError: ''}); return; } - if (email === '' || !utils.isEmail(email)) { - this.setState({emailError: 'Please enter a valid email address'}); + if (email !== confirmEmail) { + this.setState({emailError: 'The new emails you entered do not match.', clientError: '', serverError: ''}); return; } - if (email !== confirmEmail) { - this.setState({emailError: 'The new emails you entered do not match'}); + if (user.email === email) { + this.updateSection(''); return; } @@ -117,7 +117,7 @@ export default class UserSettingsGeneralTab extends React.Component { this.submitUser(user, true); } submitUser(user, emailUpdated) { - client.updateUser(user, + Client.updateUser(user, () => { this.updateSection(''); AsyncClient.getMe(); @@ -130,13 +130,13 @@ export default class UserSettingsGeneralTab extends React.Component { } }, (err) => { - var state = this.setupInitialState(this.props); + let serverError; if (err.message) { - state.serverError = err.message; + serverError = err.message; } else { - state.serverError = err; + serverError = err; } - this.setState(state); + this.setState({serverError, emailError: '', clientError: ''}); } ); } @@ -151,10 +151,10 @@ export default class UserSettingsGeneralTab extends React.Component { return; } - var picture = this.state.picture; + const picture = this.state.picture; if (picture.type !== 'image/jpeg' && picture.type !== 'image/png') { - this.setState({clientError: 'Only JPG or PNG images may be used for profile pictures'}); + this.setState({clientError: 'Only JPG or PNG images may be used for profile pictures.'}); return; } @@ -162,17 +162,17 @@ export default class UserSettingsGeneralTab extends React.Component { formData.append('image', picture, picture.name); this.setState({loadingPicture: true}); - client.uploadProfileImage(formData, - function imageUploadSuccess() { + Client.uploadProfileImage(formData, + () => { this.submitActive = false; AsyncClient.getMe(); window.location.reload(); - }.bind(this), - function imageUploadFailure(err) { + }, + (err) => { var state = this.setupInitialState(this.props); state.serverError = err.message; this.setState(state); - }.bind(this) + } ); } updateUsername(e) { @@ -205,34 +205,34 @@ export default class UserSettingsGeneralTab extends React.Component { } updateSection(section) { const emailChangeInProgress = this.state.emailChangeInProgress; - this.setState(assign({}, this.setupInitialState(this.props), {emailChangeInProgress: emailChangeInProgress, clientError: '', serverError: '', emailError: ''})); + this.setState(Object.assign({}, this.setupInitialState(this.props), {emailChangeInProgress, clientError: '', serverError: '', emailError: ''})); this.submitActive = false; this.props.updateSection(section); } setupInitialState(props) { - var user = props.user; + const user = props.user; return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname, email: user.email, confirmEmail: '', picture: null, loadingPicture: false, emailChangeInProgress: false}; } render() { - var user = this.props.user; + const user = this.props.user; - var clientError = null; + let clientError = null; if (this.state.clientError) { clientError = this.state.clientError; } - var serverError = null; + let serverError = null; if (this.state.serverError) { serverError = this.state.serverError; } - var emailError = null; + let emailError = null; if (this.state.emailError) { emailError = this.state.emailError; } - var nameSection; - var inputs = []; + let nameSection; + const inputs = []; if (this.props.activeSection === 'name') { inputs.push( @@ -298,15 +298,15 @@ export default class UserSettingsGeneralTab extends React.Component { submit={this.submitName} server_error={serverError} client_error={clientError} - updateSection={function clearSection(e) { + updateSection={(e) => { this.updateSection(''); e.preventDefault(); - }.bind(this)} + }} extraInfo={extraInfo} /> ); } else { - var fullName = ''; + let fullName = ''; if (user.first_name && user.last_name) { fullName = user.first_name + ' ' + user.last_name; @@ -320,17 +320,17 @@ export default class UserSettingsGeneralTab extends React.Component { <SettingItemMin title='Full Name' describe={fullName} - updateSection={function updateNameSection() { + updateSection={() => { this.updateSection('name'); - }.bind(this)} + }} /> ); } - var nicknameSection; + let nicknameSection; if (this.props.activeSection === 'nickname') { let nicknameLabel = 'Nickname'; - if (utils.isMobile()) { + if (Utils.isMobile()) { nicknameLabel = ''; } @@ -364,10 +364,10 @@ export default class UserSettingsGeneralTab extends React.Component { submit={this.submitNickname} server_error={serverError} client_error={clientError} - updateSection={function clearSection(e) { + updateSection={(e) => { this.updateSection(''); e.preventDefault(); - }.bind(this)} + }} extraInfo={extraInfo} /> ); @@ -376,17 +376,17 @@ export default class UserSettingsGeneralTab extends React.Component { <SettingItemMin title='Nickname' describe={UserStore.getCurrentUser().nickname} - updateSection={function updateNicknameSection() { + updateSection={() => { this.updateSection('nickname'); - }.bind(this)} + }} /> ); } - var usernameSection; + let usernameSection; if (this.props.activeSection === 'username') { let usernameLabel = 'Username'; - if (utils.isMobile()) { + if (Utils.isMobile()) { usernameLabel = ''; } @@ -416,10 +416,10 @@ export default class UserSettingsGeneralTab extends React.Component { submit={this.submitUsername} server_error={serverError} client_error={clientError} - updateSection={function clearSection(e) { + updateSection={(e) => { this.updateSection(''); e.preventDefault(); - }.bind(this)} + }} extraInfo={extraInfo} /> ); @@ -428,22 +428,23 @@ export default class UserSettingsGeneralTab extends React.Component { <SettingItemMin title='Username' describe={UserStore.getCurrentUser().username} - updateSection={function updateUsernameSection() { + updateSection={() => { this.updateSection('username'); - }.bind(this)} + }} /> ); } - var emailSection; + + let emailSection; if (this.props.activeSection === 'email') { const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true'; const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true'; - let helpText = 'Email is used for notifications, and requires verification if changed.'; + let helpText = 'Email is used for sign-in, notifications, and password reset. Email requires verification if changed.'; if (!emailEnabled) { helpText = <div className='setting-list__hint text-danger'>{'Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'}</div>; } else if (!emailVerificationEnabled) { - helpText = 'Email is used for notifications.'; + helpText = 'Email is used for sign-in, notifications, and password reset.'; } else if (this.state.emailChangeInProgress) { const newEmail = UserStore.getCurrentUser().email; if (newEmail) { @@ -507,10 +508,10 @@ export default class UserSettingsGeneralTab extends React.Component { submit={submit} server_error={serverError} client_error={emailError} - updateSection={function clearSection(e) { + updateSection={(e) => { this.updateSection(''); e.preventDefault(); - }.bind(this)} + }} /> ); } else { @@ -534,26 +535,26 @@ export default class UserSettingsGeneralTab extends React.Component { <SettingItemMin title='Email' describe={describe} - updateSection={function updateEmailSection() { + updateSection={() => { this.updateSection('email'); - }.bind(this)} + }} /> ); } - var pictureSection; + let pictureSection; if (this.props.activeSection === 'picture') { pictureSection = ( <SettingPicture title='Profile Picture' submit={this.submitPicture} - src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update + '&' + utils.getSessionIndex()} + src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update + '&' + Utils.getSessionIndex()} server_error={serverError} client_error={clientError} - updateSection={function clearSection(e) { + updateSection={(e) => { this.updateSection(''); e.preventDefault(); - }.bind(this)} + }} picture={this.state.picture} pictureChange={this.updatePicture} submitActive={this.submitActive} @@ -561,17 +562,17 @@ export default class UserSettingsGeneralTab extends React.Component { /> ); } else { - var minMessage = 'Click \'Edit\' to upload an image.'; + let minMessage = 'Click \'Edit\' to upload an image.'; if (user.last_picture_update) { - minMessage = 'Image last updated ' + utils.displayDate(user.last_picture_update); + minMessage = 'Image last updated ' + Utils.displayDate(user.last_picture_update); } pictureSection = ( <SettingItemMin title='Profile Picture' describe={minMessage} - updateSection={function updatePictureSection() { + updateSection={() => { this.updateSection('picture'); - }.bind(this)} + }} /> ); } @@ -619,10 +620,10 @@ export default class UserSettingsGeneralTab extends React.Component { } UserSettingsGeneralTab.propTypes = { - user: React.PropTypes.object, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func, - activeSection: React.PropTypes.string, + user: React.PropTypes.object.isRequired, + updateSection: React.PropTypes.func.isRequired, + updateTab: React.PropTypes.func.isRequired, + activeSection: React.PropTypes.string.isRequired, closeModal: React.PropTypes.func.isRequired, collapseModal: React.PropTypes.func.isRequired }; diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 744a6beea..a86510eb3 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); -var ManageIncomingHooks = require('./manage_incoming_hooks.jsx'); -var ManageOutgoingHooks = require('./manage_outgoing_hooks.jsx'); +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import ManageIncomingHooks from './manage_incoming_hooks.jsx'; +import ManageOutgoingHooks from './manage_outgoing_hooks.jsx'; export default class UserSettingsIntegrationsTab extends React.Component { constructor(props) { diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index 4dcf32cb9..36e1aa217 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -1,15 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const ConfirmModal = require('../confirm_modal.jsx'); +import ConfirmModal from '../confirm_modal.jsx'; const Modal = ReactBootstrap.Modal; -const SettingsSidebar = require('../settings_sidebar.jsx'); -const UserSettings = require('./user_settings.jsx'); +import SettingsSidebar from '../settings_sidebar.jsx'; +import UserSettings from './user_settings.jsx'; export default class UserSettingsModal extends React.Component { constructor(props) { super(props); + this.handleShow = this.handleShow.bind(this); this.handleHide = this.handleHide.bind(this); this.handleHidden = this.handleHidden.bind(this); this.handleCollapse = this.handleCollapse.bind(this); @@ -33,12 +34,24 @@ export default class UserSettingsModal extends React.Component { this.requireConfirm = false; } + componentDidMount() { + if (this.props.show) { + this.handleShow(); + } + } + componentDidUpdate(prevProps) { - if (!prevProps.show && this.props.show) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 300); - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); - } + if (this.props.show && !prevProps.show) { + this.handleShow(); + } + } + + handleShow() { + if ($(window).width() > 768) { + $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 50); } } diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx index c6f47804f..f762405af 100644 --- a/web/react/components/user_settings/user_settings_notifications.jsx +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -1,16 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); -var client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var utils = require('../../utils/utils.jsx'); +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; + +import UserStore from '../../stores/user_store.jsx'; + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import * as Utils from '../../utils/utils.jsx'; function getNotificationsStateFromStores() { var user = UserStore.getCurrentUser(); - var soundNeeded = !utils.isBrowserFirefox(); + var soundNeeded = !Utils.isBrowserFirefox(); var sound = 'true'; if (user.notify_props && user.notify_props.desktop_sound) { @@ -76,7 +78,9 @@ export default class NotificationsTab extends React.Component { super(props); this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); this.updateSection = this.updateSection.bind(this); + this.updateState = this.updateState.bind(this); this.onListenerChange = this.onListenerChange.bind(this); this.handleNotifyRadio = this.handleNotifyRadio.bind(this); this.handleEmailRadio = this.handleEmailRadio.bind(this); @@ -116,7 +120,7 @@ export default class NotificationsTab extends React.Component { data.all = this.state.allKey.toString(); data.channel = this.state.channelKey.toString(); - client.updateUserNotifyProps(data, + Client.updateUserNotifyProps(data, function success() { this.props.updateSection(''); AsyncClient.getMe(); @@ -126,10 +130,21 @@ export default class NotificationsTab extends React.Component { }.bind(this) ); } + handleCancel(e) { + this.updateState(); + this.props.updateSection(''); + e.preventDefault(); + } updateSection(section) { - this.setState(getNotificationsStateFromStores()); + this.updateState(); this.props.updateSection(section); } + updateState() { + const newState = getNotificationsStateFromStores(); + if (!Utils.areObjectsEqual(newState, this.state)) { + this.setState(newState); + } + } componentDidMount() { UserStore.addChangeListener(this.onListenerChange); } @@ -137,10 +152,7 @@ export default class NotificationsTab extends React.Component { UserStore.removeChangeListener(this.onListenerChange); } onListenerChange() { - var newState = getNotificationsStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } + this.updateState(); } handleNotifyRadio(notifyLevel) { this.setState({notifyLevel: notifyLevel}); @@ -243,11 +255,6 @@ export default class NotificationsTab extends React.Component { </div> ); - handleUpdateDesktopSection = function updateDesktopSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - const extraInfo = <span>{'Desktop notifications are available on Firefox, Safari, and Chrome.'}</span>; desktopSection = ( @@ -257,7 +264,7 @@ export default class NotificationsTab extends React.Component { inputs={inputs} submit={this.handleSubmit} server_error={serverError} - updateSection={handleUpdateDesktopSection} + updateSection={this.handleCancel} /> ); } else { @@ -322,11 +329,6 @@ export default class NotificationsTab extends React.Component { </div> ); - handleUpdateSoundSection = function updateSoundSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - const extraInfo = <span>{'Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'}</span>; soundSection = ( @@ -336,7 +338,7 @@ export default class NotificationsTab extends React.Component { inputs={inputs} submit={this.handleSubmit} server_error={serverError} - updateSection={handleUpdateSoundSection} + updateSection={this.handleCancel} /> ); } else { @@ -403,18 +405,13 @@ export default class NotificationsTab extends React.Component { </div> ); - handleUpdateEmailSection = function updateEmailSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - emailSection = ( <SettingItemMax title='Email notifications' inputs={inputs} submit={this.handleSubmit} server_error={serverError} - updateSection={handleUpdateEmailSection} + updateSection={this.handleCancel} /> ); } else { @@ -510,7 +507,7 @@ export default class NotificationsTab extends React.Component { }.bind(this); inputs.push( <div key='userNotificationAllOption'> - <div className='checkbox'> + <div className='checkbox hidden'> <label> <input type='checkbox' @@ -564,17 +561,13 @@ export default class NotificationsTab extends React.Component { </div> ); - handleUpdateKeysSection = function updateKeysSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); keysSection = ( <SettingItemMax title='Words that trigger mentions' inputs={inputs} submit={this.handleSubmit} server_error={serverError} - updateSection={handleUpdateKeysSection} + updateSection={this.handleCancel} /> ); } else { @@ -588,9 +581,11 @@ export default class NotificationsTab extends React.Component { if (this.state.mentionKey) { keys.push('@' + user.username); } - if (this.state.allKey) { - keys.push('@all'); - } + + // if (this.state.allKey) { + // keys.push('@all'); + // } + if (this.state.channelKey) { keys.push('@channel'); } @@ -649,7 +644,7 @@ export default class NotificationsTab extends React.Component { ref='wrapper' className='user-settings' > - <h3 className='tab-header'>Notifications</h3> + <h3 className='tab-header'>{'Notifications'}</h3> <div className='divider-dark first'/> {desktopSection} <div className='divider-light'/> diff --git a/web/react/components/user_settings/user_settings_security.jsx b/web/react/components/user_settings/user_settings_security.jsx index 61d13ed8b..fa2fecf07 100644 --- a/web/react/components/user_settings/user_settings_security.jsx +++ b/web/react/components/user_settings/user_settings_security.jsx @@ -1,46 +1,26 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); -var AccessHistoryModal = require('../access_history_modal.jsx'); -var ActivityLogModal = require('../activity_log_modal.jsx'); -var Client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var Constants = require('../../utils/constants.jsx'); +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import AccessHistoryModal from '../access_history_modal.jsx'; +import ActivityLogModal from '../activity_log_modal.jsx'; +import ToggleModalButton from '../toggle_modal_button.jsx'; +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import Constants from '../../utils/constants.jsx'; export default class SecurityTab extends React.Component { constructor(props) { super(props); - this.showAccessHistoryModal = this.showAccessHistoryModal.bind(this); - this.showActivityLogModal = this.showActivityLogModal.bind(this); - this.hideModals = this.hideModals.bind(this); this.submitPassword = this.submitPassword.bind(this); this.updateCurrentPassword = this.updateCurrentPassword.bind(this); this.updateNewPassword = this.updateNewPassword.bind(this); this.updateConfirmPassword = this.updateConfirmPassword.bind(this); this.setupInitialState = this.setupInitialState.bind(this); - const state = this.setupInitialState(); - state.showAccessHistoryModal = false; - state.showActivityLogModal = false; - this.state = state; - } - showAccessHistoryModal() { - this.props.setEnforceFocus(false); - this.setState({showAccessHistoryModal: true}); - } - showActivityLogModal() { - this.props.setEnforceFocus(false); - this.setState({showActivityLogModal: true}); - } - hideModals() { - this.props.setEnforceFocus(true); - this.setState({ - showAccessHistoryModal: false, - showActivityLogModal: false - }); + this.state = this.setupInitialState(); } submitPassword(e) { e.preventDefault(); @@ -258,30 +238,20 @@ export default class SecurityTab extends React.Component { {passwordSection} <div className='divider-dark'/> <br></br> - <a + <ToggleModalButton className='security-links theme' - href='#' - onClick={this.showAccessHistoryModal} + dialogType={AccessHistoryModal} > <i className='fa fa-clock-o'></i>View Access History - </a> + </ToggleModalButton> <b> </b> - <a + <ToggleModalButton className='security-links theme' - href='#' - onClick={this.showActivityLogModal} + dialogType={ActivityLogModal} > - <i className='fa fa-globe'></i>View and Logout of Active Sessions - </a> + <i className='fa fa-clock-o'></i>{'View and Logout of Active Sessions'} + </ToggleModalButton> </div> - <AccessHistoryModal - show={this.state.showAccessHistoryModal} - onModalDismissed={this.hideModals} - /> - <ActivityLogModal - show={this.state.showActivityLogModal} - onModalDismissed={this.hideModals} - /> </div> ); } diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 92d7cd835..820f8fd8e 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Client = require('../utils/client.jsx'); -const Utils = require('../utils/utils.jsx'); -const Constants = require('../utils/constants.jsx'); -const ViewImagePopoverBar = require('./view_image_popover_bar.jsx'); +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; +import ViewImagePopoverBar from './view_image_popover_bar.jsx'; const Modal = ReactBootstrap.Modal; const KeyCodes = Constants.KeyCodes; @@ -423,24 +423,29 @@ export default class ViewImageModal extends React.Component { onClick={this.props.onModalDismissed} > <div - className={'image-wrapper ' + bgClass} - onMouseEnter={this.onMouseEnterImage} - onMouseLeave={this.onMouseLeaveImage} - onClick={(e) => e.stopPropagation()} + className={'image-wrapper'} + onClick={this.props.onModalDismissed} > <div - className={closeButtonClass} - onClick={this.props.onModalDismissed} - /> - {content} - <ViewImagePopoverBar - show={this.state.showFooter} - fileId={this.state.imgId} - totalFiles={this.props.filenames.length} - filename={name} - fileURL={fileUrl} - getPublicLink={this.getPublicLink} - /> + className={bgClass} + onMouseEnter={this.onMouseEnterImage} + onMouseLeave={this.onMouseLeaveImage} + onClick={(e) => e.stopPropagation()} + > + <div + className={closeButtonClass} + onClick={this.props.onModalDismissed} + /> + {content} + <ViewImagePopoverBar + show={this.state.showFooter} + fileId={this.state.imgId} + totalFiles={this.props.filenames.length} + filename={name} + fileURL={fileUrl} + getPublicLink={this.getPublicLink} + /> + </div> </div> {leftArrow} {rightArrow} diff --git a/web/react/dispatcher/app_dispatcher.jsx b/web/react/dispatcher/app_dispatcher.jsx index 388e82a51..648f0fa0d 100644 --- a/web/react/dispatcher/app_dispatcher.jsx +++ b/web/react/dispatcher/app_dispatcher.jsx @@ -1,17 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Dispatcher = require('flux').Dispatcher; -var assign = require('object-assign'); +import * as Flux from 'flux'; -var Constants = require('../utils/constants.jsx'); -var PayloadSources = Constants.PayloadSources; +import Constants from '../utils/constants.jsx'; +const PayloadSources = Constants.PayloadSources; -var AppDispatcher = assign(new Dispatcher(), { +const AppDispatcher = Object.assign(new Flux.Dispatcher(), { handleServerAction: function performServerAction(action) { var payload = { source: PayloadSources.SERVER_ACTION, - action: action + action }; this.dispatch(payload); }, @@ -19,7 +18,7 @@ var AppDispatcher = assign(new Dispatcher(), { handleViewAction: function performViewAction(action) { var payload = { source: PayloadSources.VIEW_ACTION, - action: action + action }; this.dispatch(payload); } diff --git a/web/react/dispatcher/event_helpers.jsx b/web/react/dispatcher/event_helpers.jsx new file mode 100644 index 000000000..297367ce9 --- /dev/null +++ b/web/react/dispatcher/event_helpers.jsx @@ -0,0 +1,182 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; + +export function emitChannelClickEvent(channel) { + AsyncClient.getChannels(true); + AsyncClient.getChannelExtraInfo(channel.id); + AsyncClient.updateLastViewedAt(channel.id); + AsyncClient.getPosts(channel.id); + + AppDispatcher.handleViewAction({ + type: ActionTypes.CLICK_CHANNEL, + name: channel.name, + id: channel.id + }); +} + +export function emitPostFocusEvent(postId) { + Client.getPostById( + postId, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_FOCUSED_POST, + postId, + post_list: data + }); + + AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS); + AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS); + } + ); +} + +export function emitPostFocusRightHandSideEvent(post) { + Client.getPost( + post.channel_id, + post.id, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST_SELECTED, + post_list: data + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_SEARCH, + results: null + }); + }, + (err) => { + AsyncClient.dispatchError(err, 'getPost'); + } + ); + + var postChannel = ChannelStore.get(post.channel_id); + Utils.switchChannel(postChannel); +} + +export function emitLoadMorePostsEvent() { + const id = ChannelStore.getCurrentId(); + loadMorePostsTop(id); +} + +export function emitLoadMorePostsFocusedTopEvent() { + const id = PostStore.getFocusedPostId(); + loadMorePostsTop(id); +} + +export function loadMorePostsTop(id) { + const earliestPostId = PostStore.getEarliestPost(id).id; + if (PostStore.requestVisibilityIncrease(id, Constants.POST_CHUNK_SIZE)) { + AsyncClient.getPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE); + } +} + +export function emitLoadMorePostsFocusedBottomEvent() { + const id = PostStore.getFocusedPostId(); + const latestPostId = PostStore.getLatestPost(id).id; + AsyncClient.getPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE); +} + +export function emitPostRecievedEvent(post) { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POST, + post + }); +} + +export function emitUserPostedEvent(post) { + AppDispatcher.handleServerAction({ + type: ActionTypes.CREATE_POST, + post + }); +} + +export function emitPostDeletedEvent(post) { + AppDispatcher.handleServerAction({ + type: ActionTypes.POST_DELETED, + post + }); +} + +export function showDeletePostModal(post, commentCount = 0) { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_DELETE_POST_MODAL, + value: true, + post, + commentCount + }); +} + +export function showGetTeamInviteLinkModal() { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL, + value: true + }); +} + +export function showInviteMemberModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, + value: true + }); +} + +export function showRegisterAppModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_REGISTER_APP_MODAL, + value: true + }); +} + +export function emitSuggestionPretextChanged(suggestionId, pretext) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, + id: suggestionId, + pretext + }); +} + +export function emitSelectNextSuggestion(suggestionId) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_SELECT_NEXT, + id: suggestionId + }); +} + +export function emitSelectPreviousSuggestion(suggestionId) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_SELECT_PREVIOUS, + id: suggestionId + }); +} + +export function emitCompleteWordSuggestion(suggestionId, term = '') { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD, + id: suggestionId, + term + }); +} + +export function emitClearSuggestions(suggestionId) { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.SUGGESTION_CLEAR_SUGGESTIONS, + id: suggestionId + }); +} + +export function emitPreferenceChangedEvent(preference) { + AppDispatcher.handleServerAction({ + type: Constants.ActionTypes.RECIEVED_PREFERENCE, + preference + }); +} diff --git a/web/react/package.json b/web/react/package.json index 9af6f5880..14b16b4e4 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -3,36 +3,46 @@ "version": "0.0.1", "private": true, "dependencies": { - "autolinker": "0.18.1", - "babel-runtime": "5.8.24", + "autolinker": "0.22.0", "flux": "2.1.1", - "highlight.js": "^8.9.1", + "highlight.js": "8.9.1", "keymirror": "0.1.1", - "marked": "0.3.5", - "object-assign": "3.0.0", + "marked": "mattermost/marked#cb85e5cc81bc7937dbb73c3c53d9532b1b97e3ca", + "object-assign": "4.0.1", "twemoji": "1.4.1" }, "devDependencies": { - "browserify": "11.2.0", - "babelify": "6.3.0", - "uglify-js": "2.4.24", - "watchify": "3.4.0", - "eslint": "1.6.0", - "eslint-plugin-react": "3.5.1" + "browserify": "12.0.1", + "babelify": "7.2.0", + "babel-preset-es2015": "6.1.18", + "babel-preset-stage-0": "6.1.18", + "babel-preset-react": "6.1.18", + "babel-plugin-transform-runtime": "6.1.4", + "uglify-js": "2.6.1", + "watchify": "3.6.1", + "eslint": "1.9.0", + "eslint-plugin-react": "3.9.0", + "exorcist": "0.4.0", + "babel-eslint": "4.1.5" }, "scripts": { "check": "", "build-libs": "browserify -r crypto -r autolinker -r flux -r keymirror -r marked -r object-assign -r twemoji | uglifyjs -c -m --screw-ie8 > ../static/js/libs.min.js", "start": "watchify --fast -x crypto -x node -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji -o ../static/js/bundle.js -v -d ./**/*.jsx", - "build": "browserify -x crypto -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji ./**/*.jsx | uglifyjs -c -m --screw-ie8 > ../static/js/bundle.min.js" + "build": "browserify -x crypto -x autolinker -x flux -x keymirror -x marked -x object-assign -x twemoji -d ./**/*.jsx | exorcist ../static/js/inter.js.map > ../static/js/tmp.js && uglifyjs ../static/js/tmp.js --in-source-map \"../static/js/inter.js.map\" --source-map \"../static/js/bundle.min.js.map\" --source-map-url \"/static/js/bundle.min.js.map\" -c -m --screw-ie8 > ../static/js/bundle.min.js && rm ../static/js/tmp.js && rm ../static/js/inter.js.map" }, "browserify": { "transform": [ [ "babelify", { - "optional": [ - "runtime" + "presets": [ + "es2015", + "react", + "stage-0" + ], + "plugins": [ + "transform-runtime" ] } ] diff --git a/web/react/pages/admin_console.jsx b/web/react/pages/admin_console.jsx index ea9ae06f4..cbd2bd80d 100644 --- a/web/react/pages/admin_console.jsx +++ b/web/react/pages/admin_console.jsx @@ -1,9 +1,9 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ErrorBar = require('../components/error_bar.jsx'); -var SelectTeamModal = require('../components/admin_console/select_team_modal.jsx'); -var AdminController = require('../components/admin_console/admin_controller.jsx'); +import ErrorBar from '../components/error_bar.jsx'; +import SelectTeamModal from '../components/admin_console/select_team_modal.jsx'; +import AdminController from '../components/admin_console/admin_controller.jsx'; export function setupAdminConsolePage(props) { ReactDOM.render( diff --git a/web/react/pages/authorize.jsx b/web/react/pages/authorize.jsx index 2bc8b539d..71f17d007 100644 --- a/web/react/pages/authorize.jsx +++ b/web/react/pages/authorize.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Authorize = require('../components/authorize.jsx'); +import Authorize from '../components/authorize.jsx'; function setupAuthorizePage(props) { ReactDOM.render( diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 8781d52a5..2122c729e 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -1,41 +1,45 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var ChannelView = require('../components/channel_view.jsx'); -var ChannelLoader = require('../components/channel_loader.jsx'); -var ErrorBar = require('../components/error_bar.jsx'); -var ErrorStore = require('../stores/error_store.jsx'); - -var MentionList = require('../components/mention_list.jsx'); -var GetLinkModal = require('../components/get_link_modal.jsx'); -var EditChannelModal = require('../components/edit_channel_modal.jsx'); -var DeleteChannelModal = require('../components/delete_channel_modal.jsx'); -var RenameChannelModal = require('../components/rename_channel_modal.jsx'); -var EditPostModal = require('../components/edit_post_modal.jsx'); -var DeletePostModal = require('../components/delete_post_modal.jsx'); -var MoreChannelsModal = require('../components/more_channels.jsx'); -var PostDeletedModal = require('../components/post_deleted_modal.jsx'); -var ChannelNotificationsModal = require('../components/channel_notifications.jsx'); -var TeamSettingsModal = require('../components/team_settings_modal.jsx'); -var TeamMembersModal = require('../components/team_members.jsx'); -var ChannelInfoModal = require('../components/channel_info_modal.jsx'); -var RemovedFromChannelModal = require('../components/removed_from_channel_modal.jsx'); -var RegisterAppModal = require('../components/register_app_modal.jsx'); -var ImportThemeModal = require('../components/user_settings/import_theme_modal.jsx'); -var InviteMemberModal = require('../components/invite_member_modal.jsx'); - -var AsyncClient = require('../utils/async_client.jsx'); -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; - -function setupChannelPage(props) { - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - name: props.ChannelName, - id: props.ChannelId - }); +import ChannelView from '../components/channel_view.jsx'; +import ChannelLoader from '../components/channel_loader.jsx'; +import ErrorBar from '../components/error_bar.jsx'; +import ErrorStore from '../stores/error_store.jsx'; + +import GetTeamInviteLinkModal from '../components/get_team_invite_link_modal.jsx'; +import RenameChannelModal from '../components/rename_channel_modal.jsx'; +import EditPostModal from '../components/edit_post_modal.jsx'; +import DeletePostModal from '../components/delete_post_modal.jsx'; +import MoreChannelsModal from '../components/more_channels.jsx'; +import PostDeletedModal from '../components/post_deleted_modal.jsx'; +import TeamSettingsModal from '../components/team_settings_modal.jsx'; +import RemovedFromChannelModal from '../components/removed_from_channel_modal.jsx'; +import RegisterAppModal from '../components/register_app_modal.jsx'; +import ImportThemeModal from '../components/user_settings/import_theme_modal.jsx'; +import InviteMemberModal from '../components/invite_member_modal.jsx'; + +import PreferenceStore from '../stores/preference_store.jsx'; + +import * as Utils from '../utils/utils.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; + +import Constants from '../utils/constants.jsx'; + +function onPreferenceChange() { + const selectedFont = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', {value: Constants.DEFAULT_FONT}).value; + Utils.applyFont(selectedFont); + PreferenceStore.removeChangeListener(onPreferenceChange); +} + +function setupChannelPage(props, team, channel) { + if (props.PostId === '') { + EventHelpers.emitChannelClickEvent(channel); + } else { + EventHelpers.emitPostFocusEvent(props.PostId); + } + PreferenceStore.addChangeListener(onPreferenceChange); AsyncClient.getAllPreferences(); // ChannelLoader must be rendered first @@ -54,27 +58,12 @@ function setupChannelPage(props) { document.getElementById('channel_view') ); - ReactDOM.render( - <MentionList id='post_textbox' />, - document.getElementById('post_mention_tab') - ); - - ReactDOM.render( - <MentionList id='reply_textbox' />, - document.getElementById('reply_mention_tab') - ); - - ReactDOM.render( - <MentionList id='edit_textbox' />, - document.getElementById('edit_mention_tab') - ); - // // Modals // ReactDOM.render( - <GetLinkModal />, - document.getElementById('get_link_modal') + <GetTeamInviteLinkModal />, + document.getElementById('get_team_invite_link_modal') ); ReactDOM.render( @@ -93,36 +82,11 @@ function setupChannelPage(props) { ); ReactDOM.render( - <TeamMembersModal teamDisplayName={props.TeamDisplayName} />, - document.getElementById('team_members_modal') - ); - - ReactDOM.render( - <EditChannelModal />, - document.getElementById('edit_channel_modal') - ); - - ReactDOM.render( - <DeleteChannelModal />, - document.getElementById('delete_channel_modal') - ); - - ReactDOM.render( <RenameChannelModal />, document.getElementById('rename_channel_modal') ); ReactDOM.render( - <ChannelNotificationsModal />, - document.getElementById('channel_notifications_modal') - ); - - ReactDOM.render( - <ChannelInfoModal />, - document.getElementById('channel_info_modal') - ); - - ReactDOM.render( <MoreChannelsModal />, document.getElementById('more_channels_modal') ); diff --git a/web/react/pages/docs.jsx b/web/react/pages/docs.jsx new file mode 100644 index 000000000..74d9c2d19 --- /dev/null +++ b/web/react/pages/docs.jsx @@ -0,0 +1,16 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Docs from '../components/docs.jsx'; + +function setupDocumentationPage(props) { + ReactDOM.render( + <Docs + site={props.Site} + />, + document.getElementById('docs') + ); +} + +global.window.mm_user = global.window.mm_user || {}; +global.window.setup_documentation_page = setupDocumentationPage; diff --git a/web/react/pages/find_team.jsx b/web/react/pages/find_team.jsx index f95fce5c6..c4653fd77 100644 --- a/web/react/pages/find_team.jsx +++ b/web/react/pages/find_team.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var FindTeam = require('../components/find_team.jsx'); +import FindTeam from '../components/find_team.jsx'; function setupFindTeamPage() { ReactDOM.render( diff --git a/web/react/pages/home.jsx b/web/react/pages/home.jsx index a59f2afd0..ff81c4994 100644 --- a/web/react/pages/home.jsx +++ b/web/react/pages/home.jsx @@ -1,12 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var Constants = require('../utils/constants.jsx'); +import TeamStore from '../stores/team_store.jsx'; +import Constants from '../utils/constants.jsx'; function setupHomePage() { - var last = ChannelStore.getLastVisitedName(); + var last = null; if (last == null || last.length === 0) { window.location = TeamStore.getCurrentTeamUrl() + '/channels/' + Constants.DEFAULT_CHANNEL; } else { diff --git a/web/react/pages/login.jsx b/web/react/pages/login.jsx index 9865e6fd2..4a565623e 100644 --- a/web/react/pages/login.jsx +++ b/web/react/pages/login.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var Login = require('../components/login.jsx'); +import Login from '../components/login.jsx'; function setupLoginPage(props) { ReactDOM.render( diff --git a/web/react/pages/password_reset.jsx b/web/react/pages/password_reset.jsx index aef9ab3f9..4a6f1dcb0 100644 --- a/web/react/pages/password_reset.jsx +++ b/web/react/pages/password_reset.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var PasswordReset = require('../components/password_reset.jsx'); +import PasswordReset from '../components/password_reset.jsx'; function setupPasswordResetPage(props) { ReactDOM.render( diff --git a/web/react/pages/signup_team.jsx b/web/react/pages/signup_team.jsx index caa93b5bf..08ea45000 100644 --- a/web/react/pages/signup_team.jsx +++ b/web/react/pages/signup_team.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SignupTeam = require('../components/signup_team.jsx'); +import SignupTeam from '../components/signup_team.jsx'; function setupSignupTeamPage(props) { var teams = []; diff --git a/web/react/pages/signup_team_complete.jsx b/web/react/pages/signup_team_complete.jsx index 44ec05b98..d5ed144a1 100644 --- a/web/react/pages/signup_team_complete.jsx +++ b/web/react/pages/signup_team_complete.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SignupTeamComplete = require('../components/signup_team_complete.jsx'); +import SignupTeamComplete from '../components/signup_team_complete.jsx'; function setupSignupTeamCompletePage(props) { ReactDOM.render( diff --git a/web/react/pages/signup_user_complete.jsx b/web/react/pages/signup_user_complete.jsx index a0e0962ff..de2c48443 100644 --- a/web/react/pages/signup_user_complete.jsx +++ b/web/react/pages/signup_user_complete.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var SignupUserComplete = require('../components/signup_user_complete.jsx'); +import SignupUserComplete from '../components/signup_user_complete.jsx'; function setupSignupUserCompletePage(props) { ReactDOM.render( diff --git a/web/react/pages/verify.jsx b/web/react/pages/verify.jsx index 7535009b3..d4ce4844d 100644 --- a/web/react/pages/verify.jsx +++ b/web/react/pages/verify.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var EmailVerify = require('../components/email_verify.jsx'); +import EmailVerify from '../components/email_verify.jsx'; global.window.setupVerifyPage = function setupVerifyPage(props) { ReactDOM.render( diff --git a/web/react/stores/admin_store.jsx b/web/react/stores/admin_store.jsx index cf16d031c..704e2ced4 100644 --- a/web/react/stores/admin_store.jsx +++ b/web/react/stores/admin_store.jsx @@ -1,17 +1,17 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var BrowserStore = require('../stores/browser_store.jsx'); +import BrowserStore from '../stores/browser_store.jsx'; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; -var LOG_CHANGE_EVENT = 'log_change'; -var CONFIG_CHANGE_EVENT = 'config_change'; -var ALL_TEAMS_EVENT = 'all_team_change'; +const LOG_CHANGE_EVENT = 'log_change'; +const CONFIG_CHANGE_EVENT = 'config_change'; +const ALL_TEAMS_EVENT = 'all_team_change'; class AdminStoreClass extends EventEmitter { constructor() { diff --git a/web/react/stores/browser_store.jsx b/web/react/stores/browser_store.jsx index 8e86ce32f..ff6ae45ea 100644 --- a/web/react/stores/browser_store.jsx +++ b/web/react/stores/browser_store.jsx @@ -1,6 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {generateId} from '../utils/utils.jsx'; + function getPrefix() { if (global.window.mm_user) { return global.window.mm_user.id + '_'; @@ -26,6 +28,7 @@ class BrowserStoreClass { this.clearAll = this.clearAll.bind(this); this.checkedLocalStorageSupported = ''; this.signalLogout = this.signalLogout.bind(this); + this.isSignallingLogout = this.isSignallingLogout.bind(this); var currentVersion = sessionStorage.getItem('storage_version'); if (currentVersion !== global.window.mm_config.Version) { @@ -72,7 +75,7 @@ class BrowserStoreClass { console.log('An error occurred while setting local storage, clearing all props'); //eslint-disable-line no-console localStorage.clear(); sessionStorage.clear(); - window.location.href = window.location.href; + window.location.reload(true); } } @@ -113,11 +116,19 @@ class BrowserStoreClass { signalLogout() { if (this.isLocalStorageSupported()) { - localStorage.setItem('__logout__', 'yes'); + // PLT-1285 store an identifier in session storage so we can catch if the logout came from this tab on IE11 + const logoutId = generateId(); + + sessionStorage.setItem('__logout__', logoutId); + localStorage.setItem('__logout__', logoutId); localStorage.removeItem('__logout__'); } } + isSignallingLogout(logoutId) { + return logoutId === sessionStorage.getItem('__logout__'); + } + /** * Preforms the given action on each item that has the given prefix * Signature for action is action(key, value) @@ -151,7 +162,14 @@ class BrowserStoreClass { } clear() { + // don't clear the logout id so IE11 can tell which tab sent a logout request + const logoutId = sessionStorage.getItem('__logout__'); + sessionStorage.clear(); + + if (logoutId) { + sessionStorage.setItem('__logout__', logoutId); + } } clearAll() { @@ -185,3 +203,4 @@ class BrowserStoreClass { var BrowserStore = new BrowserStoreClass(); export default BrowserStore; +window.BrowserStore = BrowserStore; diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx index cc0d0d14b..afc960fcf 100644 --- a/web/react/stores/channel_store.jsx +++ b/web/react/stores/channel_store.jsx @@ -1,19 +1,18 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; var Utils; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +const NotificationPrefs = Constants.NotificationPrefs; -var BrowserStore = require('../stores/browser_store.jsx'); - -var CHANGE_EVENT = 'change'; -var LEAVE_EVENT = 'leave'; -var MORE_CHANGE_EVENT = 'change'; -var EXTRA_INFO_EVENT = 'extra_info'; +const CHANGE_EVENT = 'change'; +const LEAVE_EVENT = 'leave'; +const MORE_CHANGE_EVENT = 'change'; +const EXTRA_INFO_EVENT = 'extra_info'; class ChannelStoreClass extends EventEmitter { constructor(props) { @@ -21,7 +20,43 @@ class ChannelStoreClass extends EventEmitter { this.setMaxListeners(11); + this.emitChange = this.emitChange.bind(this); + this.addChangeListener = this.addChangeListener.bind(this); + this.removeChangeListener = this.removeChangeListener.bind(this); + this.emitMoreChange = this.emitMoreChange.bind(this); + this.addMoreChangeListener = this.addMoreChangeListener.bind(this); + this.removeMoreChangeListener = this.removeMoreChangeListener.bind(this); + this.emitExtraInfoChange = this.emitExtraInfoChange.bind(this); + this.addExtraInfoChangeListener = this.addExtraInfoChangeListener.bind(this); + this.removeExtraInfoChangeListener = this.removeExtraInfoChangeListener.bind(this); + this.emitLeave = this.emitLeave.bind(this); + this.addLeaveListener = this.addLeaveListener.bind(this); + this.removeLeaveListener = this.removeLeaveListener.bind(this); + this.findFirstBy = this.findFirstBy.bind(this); + this.get = this.get.bind(this); + this.getMember = this.getMember.bind(this); + this.getByName = this.getByName.bind(this); + this.pSetPostMode = this.pSetPostMode.bind(this); + this.getPostMode = this.getPostMode.bind(this); + this.setUnreadCount = this.setUnreadCount.bind(this); + this.setUnreadCounts = this.setUnreadCounts.bind(this); + this.getUnreadCount = this.getUnreadCount.bind(this); + this.getUnreadCounts = this.getUnreadCounts.bind(this); + this.currentId = null; + this.postMode = this.POST_MODE_CHANNEL; + this.channels = []; + this.channelMembers = {}; + this.moreChannels = {}; + this.moreChannels.loading = true; + this.extraInfos = {}; + this.unreadCounts = {}; + } + get POST_MODE_CHANNEL() { + return 1; + } + get POST_MODE_FOCUS() { + return 2; } emitChange() { this.emit(CHANGE_EVENT); @@ -90,29 +125,19 @@ class ChannelStoreClass extends EventEmitter { setCurrentId(id) { this.currentId = id; } - setLastVisitedName(name) { - if (name == null) { - BrowserStore.removeItem('last_visited_name'); - } else { - BrowserStore.setItem('last_visited_name', name); - } - } - getLastVisitedName() { - return BrowserStore.getItem('last_visited_name'); - } resetCounts(id) { - var cm = this.pGetChannelMembers(); + const cm = this.channelMembers; for (var cmid in cm) { if (cm[cmid].channel_id === id) { var c = this.get(id); if (c) { cm[cmid].msg_count = this.get(id).total_msg_count; cm[cmid].mention_count = 0; + this.setUnreadCount(id); } break; } } - this.pStoreChannelMembers(cm); } getCurrentId() { return this.currentId; @@ -142,18 +167,7 @@ class ChannelStoreClass extends EventEmitter { this.emitChange(); } getCurrentExtraInfo() { - var currentId = this.getCurrentId(); - var extra = null; - - if (currentId) { - extra = this.pGetExtraInfos()[currentId]; - } - - if (extra == null) { - extra = {members: []}; - } - - return extra; + return this.getExtraInfo(this.getCurrentId()); } getExtraInfo(channelId) { var extra = null; @@ -162,7 +176,10 @@ class ChannelStoreClass extends EventEmitter { extra = this.pGetExtraInfos()[channelId]; } - if (extra == null) { + if (extra) { + // create a defensive copy + extra = JSON.parse(JSON.stringify(extra)); + } else { extra = {members: []}; } @@ -192,10 +209,10 @@ class ChannelStoreClass extends EventEmitter { this.pStoreChannels(channels); } pStoreChannels(channels) { - BrowserStore.setItem('channels', channels); + this.channels = channels; } pGetChannels() { - return BrowserStore.getItem('channels', []); + return this.channels; } pStoreChannelMember(channelMember) { var members = this.pGetChannelMembers(); @@ -203,49 +220,90 @@ class ChannelStoreClass extends EventEmitter { this.pStoreChannelMembers(members); } pStoreChannelMembers(channelMembers) { - BrowserStore.setItem('channel_members', channelMembers); + this.channelMembers = channelMembers; } pGetChannelMembers() { - return BrowserStore.getItem('channel_members', {}); + return this.channelMembers; } pStoreMoreChannels(channels) { - BrowserStore.setItem('more_channels', channels); + this.moreChannels = channels; } pGetMoreChannels() { - var channels = BrowserStore.getItem('more_channels'); - - if (channels == null) { - channels = {}; - channels.loading = true; - } - - return channels; + return this.moreChannels; } pStoreExtraInfos(extraInfos) { - BrowserStore.setItem('extra_infos', extraInfos); + this.extraInfos = extraInfos; } pGetExtraInfos() { - return BrowserStore.getItem('extra_infos', {}); + return this.extraInfos; } isDefault(channel) { return channel.name === Constants.DEFAULT_CHANNEL; } + + pSetPostMode(mode) { + this.postMode = mode; + } + + getPostMode() { + return this.postMode; + } + + setUnreadCount(id) { + const ch = this.get(id); + const chMember = this.getMember(id); + + let chMentionCount = chMember.mention_count; + let chUnreadCount = ch.total_msg_count - chMember.msg_count - chMentionCount; + + if (ch.type === 'D') { + chMentionCount = chUnreadCount; + chUnreadCount = 0; + } else if (chMember.notify_props && chMember.notify_props.mark_unread === NotificationPrefs.MENTION) { + chUnreadCount = 0; + } + + this.unreadCounts[id] = {msgs: chUnreadCount, mentions: chMentionCount}; + } + + setUnreadCounts() { + const channels = this.getAll(); + channels.forEach((ch) => { + this.setUnreadCount(ch.id); + }); + } + + getUnreadCount(id) { + return this.unreadCounts[id] || {msgs: 0, mentions: 0}; + } + + getUnreadCounts() { + return this.unreadCounts; + } } var ChannelStore = new ChannelStoreClass(); -ChannelStore.dispatchToken = AppDispatcher.register(function handleAction(payload) { +ChannelStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; var currentId; switch (action.type) { case ActionTypes.CLICK_CHANNEL: ChannelStore.setCurrentId(action.id); - ChannelStore.setLastVisitedName(action.name); ChannelStore.resetCounts(action.id); + ChannelStore.pSetPostMode(ChannelStore.POST_MODE_CHANNEL); ChannelStore.emitChange(); break; + case ActionTypes.RECIEVED_FOCUSED_POST: { + const post = action.post_list.posts[action.postId]; + ChannelStore.setCurrentId(post.channel_id); + ChannelStore.pSetPostMode(ChannelStore.POST_MODE_FOCUS); + ChannelStore.emitChange(); + break; + } + case ActionTypes.RECIEVED_CHANNELS: ChannelStore.pStoreChannels(action.channels); ChannelStore.pStoreChannelMembers(action.members); @@ -253,16 +311,20 @@ ChannelStore.dispatchToken = AppDispatcher.register(function handleAction(payloa if (currentId) { ChannelStore.resetCounts(currentId); } + ChannelStore.setUnreadCounts(); ChannelStore.emitChange(); break; case ActionTypes.RECIEVED_CHANNEL: ChannelStore.pStoreChannel(action.channel); - ChannelStore.pStoreChannelMember(action.member); + if (action.member) { + ChannelStore.pStoreChannelMember(action.member); + } currentId = ChannelStore.getCurrentId(); if (currentId) { ChannelStore.resetCounts(currentId); } + ChannelStore.setUnreadCount(action.channel.id); ChannelStore.emitChange(); break; diff --git a/web/react/stores/error_store.jsx b/web/react/stores/error_store.jsx index 775b8e006..8fb051138 100644 --- a/web/react/stores/error_store.jsx +++ b/web/react/stores/error_store.jsx @@ -1,15 +1,15 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; -var BrowserStore = require('../stores/browser_store.jsx'); +import BrowserStore from '../stores/browser_store.jsx'; -var CHANGE_EVENT = 'change'; +const CHANGE_EVENT = 'change'; class ErrorStoreClass extends EventEmitter { constructor() { diff --git a/web/react/stores/modal_store.jsx b/web/react/stores/modal_store.jsx index dc65d48da..9f33cf022 100644 --- a/web/react/stores/modal_store.jsx +++ b/web/react/stores/modal_store.jsx @@ -1,10 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const EventEmitter = require('events').EventEmitter; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -const Constants = require('../utils/constants.jsx'); +import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; class ModalStoreClass extends EventEmitter { @@ -27,12 +27,16 @@ class ModalStoreClass extends EventEmitter { } handleEventPayload(payload) { - const action = payload.action; + // toggle event handlers should accept a boolean show/hide value and can accept a map of arguments + const {type, value, ...args} = payload.action; //eslint-disable-line no-redeclare - switch (action.type) { + switch (type) { case ActionTypes.TOGGLE_IMPORT_THEME_MODAL: case ActionTypes.TOGGLE_INVITE_MEMBER_MODAL: - this.emit(action.type, action.value); + case ActionTypes.TOGGLE_DELETE_POST_MODAL: + case ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL: + case ActionTypes.TOGGLE_REGISTER_APP_MODAL: + this.emit(type, value, args); break; } } diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 0fe253310..2212edadb 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -1,20 +1,21 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var ChannelStore = require('../stores/channel_store.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); +import ChannelStore from '../stores/channel_store.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; +import UserStore from '../stores/user_store.jsx'; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; -var CHANGE_EVENT = 'change'; -var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; -var EDIT_POST_EVENT = 'edit_post'; -var POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; +const CHANGE_EVENT = 'change'; +const FOCUSED_POST_CHANGE = 'focused_post_change'; +const EDIT_POST_EVENT = 'edit_post'; +const POSTS_VIEW_JUMP_EVENT = 'post_list_jump'; +const SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; class PostStoreClass extends EventEmitter { constructor() { @@ -24,10 +25,6 @@ class PostStoreClass extends EventEmitter { this.addChangeListener = this.addChangeListener.bind(this); this.removeChangeListener = this.removeChangeListener.bind(this); - this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); - this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); - this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); - this.emitEditPost = this.emitEditPost.bind(this); this.addEditPostListener = this.addEditPostListener.bind(this); this.removeEditPostListener = this.removeEditPostListner.bind(this); @@ -36,26 +33,50 @@ class PostStoreClass extends EventEmitter { this.addPostsViewJumpListener = this.addPostsViewJumpListener.bind(this); this.removePostsViewJumpListener = this.removePostsViewJumpListener.bind(this); - this.getCurrentPosts = this.getCurrentPosts.bind(this); + this.emitPostFocused = this.emitPostFocused.bind(this); + this.addPostFocusedListener = this.addPostFocusedListener.bind(this); + this.removePostFocusedListener = this.removePostFocusedListener.bind(this); + + this.makePostsInfo = this.makePostsInfo.bind(this); + + this.getPost = this.getPost.bind(this); + this.getAllPosts = this.getAllPosts.bind(this); + this.getEarliestPost = this.getEarliestPost.bind(this); + this.getLatestPost = this.getLatestPost.bind(this); + this.getVisiblePosts = this.getVisiblePosts.bind(this); + this.getVisibilityAtTop = this.getVisibilityAtTop.bind(this); + this.getVisibilityAtBottom = this.getVisibilityAtBottom.bind(this); + this.requestVisibilityIncrease = this.requestVisibilityIncrease.bind(this); + this.getFocusedPostId = this.getFocusedPostId.bind(this); + this.storePosts = this.storePosts.bind(this); - this.pStorePosts = this.pStorePosts.bind(this); - this.getPosts = this.getPosts.bind(this); this.storePost = this.storePost.bind(this); - this.pStorePost = this.pStorePost.bind(this); + this.storeFocusedPost = this.storeFocusedPost.bind(this); + this.checkBounds = this.checkBounds.bind(this); + + this.clearFocusedPost = this.clearFocusedPost.bind(this); + this.clearChannelVisibility = this.clearChannelVisibility.bind(this); + this.removePost = this.removePost.bind(this); - this.storePendingPost = this.storePendingPost.bind(this); - this.pStorePendingPosts = this.pStorePendingPosts.bind(this); + this.getPendingPosts = this.getPendingPosts.bind(this); - this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this); - this.storeUnseenDeletedPosts = this.storeUnseenDeletedPosts.bind(this); - this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this); - this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this); + this.storePendingPost = this.storePendingPost.bind(this); this.removePendingPost = this.removePendingPost.bind(this); - this.pRemovePendingPost = this.pRemovePendingPost.bind(this); this.clearPendingPosts = this.clearPendingPosts.bind(this); this.updatePendingPost = this.updatePendingPost.bind(this); + + this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this); + this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this); + this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this); + + // These functions are bad and work should be done to remove this system when the RHS dies this.storeSelectedPost = this.storeSelectedPost.bind(this); this.getSelectedPost = this.getSelectedPost.bind(this); + this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); + this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); + this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); + this.selectedPost = null; + this.getEmptyDraft = this.getEmptyDraft.bind(this); this.storeCurrentDraft = this.storeCurrentDraft.bind(this); this.getCurrentDraft = this.getCurrentDraft.bind(this); @@ -68,6 +89,10 @@ class PostStoreClass extends EventEmitter { this.storeLatestUpdate = this.storeLatestUpdate.bind(this); this.getLatestUpdate = this.getLatestUpdate.bind(this); this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this); + this.getCommentCount = this.getCommentCount.bind(this); + + this.postsInfo = {}; + this.currentFocusedPostId = null; } emitChange() { this.emit(CHANGE_EVENT); @@ -81,16 +106,16 @@ class PostStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT, callback); } - emitSelectedPostChange(fromSearch) { - this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); + emitPostFocused() { + this.emit(FOCUSED_POST_CHANGE); } - addSelectedPostChangeListener(callback) { - this.on(SELECTED_POST_CHANGE_EVENT, callback); + addPostFocusedListener(callback) { + this.on(FOCUSED_POST_CHANGE, callback); } - removeSelectedPostChangeListener(callback) { - this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); + removePostFocusedListener(callback) { + this.removeListener(FOCUSED_POST_CHANGE, callback); } emitEditPost(post) { @@ -129,101 +154,168 @@ class PostStoreClass extends EventEmitter { this.emitPostsViewJump(Constants.PostsViewJumpTypes.SIDEBAR_OPEN, null); } - getCurrentPosts() { - var currentId = ChannelStore.getCurrentId(); + // All this does is makes sure the postsInfo is not null for the specified channel + makePostsInfo(id) { + if (!this.postsInfo.hasOwnProperty(id)) { + this.postsInfo[id] = {}; + } + } + + getPost(channelId, postId) { + const posts = this.postsInfo[channelId].postList; + let post = null; + + if (posts.posts.hasOwnProperty(postId)) { + post = Object.assign({}, posts.posts[postId]); + } + + return post; + } + + getAllPosts(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return Object.assign({}, this.postsInfo[id].postList); + } + + return null; + } + + getEarliestPost(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].postList.posts[this.postsInfo[id].postList.order[this.postsInfo[id].postList.order.length - 1]]; + } + + return null; + } - if (currentId != null) { - return this.getPosts(currentId); + getLatestPost(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].postList.posts[this.postsInfo[id].postList.order[0]]; } + return null; } - storePosts(channelId, newPostsView) { - if (isPostListNull(newPostsView)) { + + getVisiblePosts(id) { + if (this.postsInfo.hasOwnProperty(id) && this.postsInfo[id].hasOwnProperty('postList')) { + const postList = JSON.parse(JSON.stringify(this.postsInfo[id].postList)); + + // Only limit visibility if we are not focused on a post + if (this.currentFocusedPostId === null) { + postList.order = postList.order.slice(0, this.postsInfo[id].endVisible); + } + + // Add pending posts + if (this.postsInfo[id].hasOwnProperty('pendingPosts')) { + Object.assign(postList.posts, this.postsInfo[id].pendingPosts.posts); + postList.order = this.postsInfo[id].pendingPosts.order.concat(postList.order); + } + + // Add deleted posts + if (this.postsInfo[id].hasOwnProperty('deletedPosts')) { + Object.assign(postList.posts, this.postsInfo[id].deletedPosts); + + for (const postID in this.postsInfo[id].deletedPosts) { + if (this.postsInfo[id].deletedPosts.hasOwnProperty(postID)) { + postList.order.push(postID); + } + } + + // Merge would be faster + postList.order.sort((a, b) => { + if (postList.posts[a].create_at > postList.posts[b].create_at) { + return -1; + } + if (postList.posts[a].create_at < postList.posts[b].create_at) { + return 1; + } + return 0; + }); + } + + return postList; + } + + return null; + } + + getVisibilityAtTop(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].atTop && this.postsInfo[id].endVisible >= this.postsInfo[id].postList.order.length; + } + + return false; + } + + getVisibilityAtBottom(id) { + if (this.postsInfo.hasOwnProperty(id)) { + return this.postsInfo[id].atBottom; + } + + return false; + } + + // Returns true if posts need to be fetched + requestVisibilityIncrease(id, ammount) { + const endVisible = this.postsInfo[id].endVisible; + const postList = this.postsInfo[id].postList; + if (this.getVisibilityAtTop(id)) { + return false; + } + this.postsInfo[id].endVisible += ammount; + this.emitChange(); + return endVisible + ammount > postList.order.length; + } + + getFocusedPostId() { + return this.currentFocusedPostId; + } + + storePosts(id, newPosts) { + if (isPostListNull(newPosts)) { return; } - var postList = makePostListNonNull(this.getPosts(channelId)); + const combinedPosts = makePostListNonNull(this.getAllPosts(id)); - for (const pid in newPostsView.posts) { - if (newPostsView.posts.hasOwnProperty(pid)) { - const np = newPostsView.posts[pid]; + for (const pid in newPosts.posts) { + if (newPosts.posts.hasOwnProperty(pid)) { + const np = newPosts.posts[pid]; if (np.delete_at === 0) { - postList.posts[pid] = np; - if (postList.order.indexOf(pid) === -1) { - postList.order.push(pid); + combinedPosts.posts[pid] = np; + if (combinedPosts.order.indexOf(pid) === -1) { + combinedPosts.order.push(pid); } } else { - if (pid in postList.posts) { - delete postList.posts[pid]; + if (pid in combinedPosts.posts) { + Reflect.deleteProperty(combinedPosts.posts, pid); } - const index = postList.order.indexOf(pid); + const index = combinedPosts.order.indexOf(pid); if (index !== -1) { - postList.order.splice(index, 1); + combinedPosts.order.splice(index, 1); } } } } - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { + combinedPosts.order.sort((a, b) => { + if (combinedPosts.posts[a].create_at > combinedPosts.posts[b].create_at) { return -1; } - if (postList.posts[a].create_at < postList.posts[b].create_at) { + if (combinedPosts.posts[a].create_at < combinedPosts.posts[b].create_at) { return 1; } return 0; }); - var latestUpdate = 0; - for (var pid in postList.posts) { - if (postList.posts[pid].update_at > latestUpdate) { - latestUpdate = postList.posts[pid].update_at; - } - } - - this.storeLatestUpdate(channelId, latestUpdate); - this.pStorePosts(channelId, postList); - this.emitChange(); - } - pStorePosts(channelId, posts) { - BrowserStore.setItem('posts_' + channelId, posts); - } - getPosts(channelId) { - return BrowserStore.getItem('posts_' + channelId); + this.makePostsInfo(id); + this.postsInfo[id].postList = combinedPosts; } - getCurrentUsersLatestPost(channelId, rootId) { - const userId = UserStore.getCurrentId(); - var postList = makePostListNonNull(this.getPosts(channelId)); - var i = 0; - var len = postList.order.length; - var lastPost = null; - - for (i; i < len; i++) { - let post = postList.posts[postList.order[i]]; - if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { - if (rootId) { - if (post.root_id === rootId || post.id === rootId) { - lastPost = post; - break; - } - } else { - lastPost = post; - break; - } - } - } - return lastPost; - } storePost(post) { - this.pStorePost(post); - this.emitChange(); - } - pStorePost(post) { - var postList = this.getPosts(post.channel_id); - postList = makePostListNonNull(postList); + const postList = makePostListNonNull(this.getAllPosts(post.channel_id)); if (post.pending_post_id !== '') { this.removePendingPost(post.channel_id, post.pending_post_id); @@ -236,65 +328,119 @@ class PostStoreClass extends EventEmitter { postList.order.unshift(post.id); } - this.pStorePosts(post.channel_id, postList); + this.makePostsInfo(post.channel_id); + this.postsInfo[post.channel_id].postList = postList; } - removePost(postId, channelId) { - var postList = this.getPosts(channelId); + + storeFocusedPost(postId, postList) { + const focusedPost = postList.posts[postId]; + if (!focusedPost) { + return; + } + this.currentFocusedPostId = postId; + this.storePosts(postId, postList); + } + + checkBounds(id, numRequested, postList, before) { + if (numRequested > postList.order.length) { + if (before) { + this.postsInfo[id].atTop = true; + } else { + this.postsInfo[id].atBottom = true; + } + } + } + + clearFocusedPost() { + if (this.currentFocusedPostId != null) { + Reflect.deleteProperty(this.postsInfo, this.currentFocusedPostId); + this.currentFocusedPostId = null; + } + } + + clearChannelVisibility(id, atBottom) { + this.makePostsInfo(id); + this.postsInfo[id].endVisible = Constants.POST_CHUNK_SIZE; + this.postsInfo[id].atTop = false; + this.postsInfo[id].atBottom = atBottom; + } + + removePost(post) { + const channelId = post.channel_id; + this.makePostsInfo(channelId); + const postList = this.postsInfo[channelId].postList; if (isPostListNull(postList)) { return; } - if (postId in postList.posts) { - delete postList.posts[postId]; + if (post.id in postList.posts) { + Reflect.deleteProperty(postList.posts, post.id); } - var index = postList.order.indexOf(postId); + const index = postList.order.indexOf(post.id); if (index !== -1) { postList.order.splice(index, 1); } - this.pStorePosts(channelId, postList); + this.postsInfo[channelId].postList = postList; } + + getPendingPosts(channelId) { + if (this.postsInfo.hasOwnProperty(channelId)) { + return this.postsInfo[channelId].pendingPosts; + } + + return null; + } + storePendingPost(post) { post.state = Constants.POST_LOADING; - var postList = this.getPendingPosts(post.channel_id); - postList = makePostListNonNull(postList); + const postList = makePostListNonNull(this.getPendingPosts(post.channel_id)); postList.posts[post.pending_post_id] = post; postList.order.unshift(post.pending_post_id); - this.pStorePendingPosts(post.channel_id, postList); + + this.makePostsInfo(post.channel_id); + this.postsInfo[post.channel_id].pendingPosts = postList; this.emitChange(); } - pStorePendingPosts(channelId, postList) { - var posts = postList.posts; - // sort failed posts to the bottom - postList.order.sort((a, b) => { - if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) { - return 1; - } - if (posts[a].state === Constants.POST_FAILED && posts[b].state === Constants.POST_LOADING) { - return -1; - } + removePendingPost(channelId, pendingPostId) { + const postList = makePostListNonNull(this.getPendingPosts(channelId)); - if (posts[a].create_at > posts[b].create_at) { - return -1; - } - if (posts[a].create_at < posts[b].create_at) { - return 1; - } + Reflect.deleteProperty(postList.posts, pendingPostId); + const index = postList.order.indexOf(pendingPostId); + if (index === -1) { + return; + } - return 0; - }); + postList.order.splice(index, 1); - BrowserStore.setGlobalItem('pending_posts_' + channelId, postList); + this.postsInfo[channelId].pendingPosts = postList; + this.emitChange(); } - getPendingPosts(channelId) { - return BrowserStore.getGlobalItem('pending_posts_' + channelId); + + clearPendingPosts(channelId) { + if (this.postsInfo.hasOwnProperty(channelId)) { + Reflect.deleteProperty(this.postsInfo[channelId], 'pendingPosts'); + } + } + + updatePendingPost(post) { + const postList = makePostListNonNull(this.getPendingPosts(post.channel_id)); + + if (postList.order.indexOf(post.pending_post_id) === -1) { + return; + } + + postList.posts[post.pending_post_id] = post; + this.postsInfo[post.channel_id].pendingPosts = postList; + this.emitChange(); } + storeUnseenDeletedPost(post) { - var posts = this.getUnseenDeletedPosts(post.channel_id); + let posts = this.getUnseenDeletedPosts(post.channel_id); if (!posts) { posts = {}; @@ -305,58 +451,68 @@ class PostStoreClass extends EventEmitter { post.filenames = []; posts[post.id] = post; - this.storeUnseenDeletedPosts(post.channel_id, posts); - } - storeUnseenDeletedPosts(channelId, posts) { - BrowserStore.setItem('deleted_posts_' + channelId, posts); + this.postsInfo[post.channel_id].deletedPosts = posts; } + getUnseenDeletedPosts(channelId) { - return BrowserStore.getItem('deleted_posts_' + channelId); + if (this.postsInfo.hasOwnProperty(channelId)) { + return this.postsInfo[channelId].deletedPosts; + } + + return null; } + clearUnseenDeletedPosts(channelId) { - BrowserStore.setItem('deleted_posts_' + channelId, {}); + if (this.postsInfo.hasOwnProperty(channelId)) { + Reflect.deleteProperty(this.postsInfo[channelId], 'deletedPosts'); + } } - removePendingPost(channelId, pendingPostId) { - this.pRemovePendingPost(channelId, pendingPostId); - this.emitChange(); + + storeSelectedPost(postList) { + this.selectedPost = postList; } - pRemovePendingPost(channelId, pendingPostId) { - var postList = this.getPendingPosts(channelId); - postList = makePostListNonNull(postList); - if (pendingPostId in postList.posts) { - delete postList.posts[pendingPostId]; - } - var index = postList.order.indexOf(pendingPostId); - if (index !== -1) { - postList.order.splice(index, 1); - } + getSelectedPost() { + return this.selectedPost; + } - this.pStorePendingPosts(channelId, postList); + emitSelectedPostChange(fromSearch) { + this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); } - clearPendingPosts() { - BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', (key) => { - BrowserStore.removeItem(key); - }); + + addSelectedPostChangeListener(callback) { + this.on(SELECTED_POST_CHANGE_EVENT, callback); } - updatePendingPost(post) { - var postList = this.getPendingPosts(post.channel_id); - postList = makePostListNonNull(postList); - if (postList.order.indexOf(post.pending_post_id) === -1) { - return; + removeSelectedPostChangeListener(callback) { + this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); + } + + getCurrentUsersLatestPost(channelId, rootId) { + const userId = UserStore.getCurrentId(); + var postList = makePostListNonNull(this.getAllPosts(channelId)); + var i = 0; + var len = postList.order.length; + var lastPost = null; + + for (i; i < len; i++) { + const post = postList.posts[postList.order[i]]; + if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { + if (rootId) { + if (post.root_id === rootId || post.id === rootId) { + lastPost = post; + break; + } + } else { + lastPost = post; + break; + } + } } - postList.posts[post.pending_post_id] = post; - this.pStorePendingPosts(post.channel_id, postList); - this.emitChange(); - } - storeSelectedPost(postList) { - BrowserStore.setItem('select_post', postList); - } - getSelectedPost() { - return BrowserStore.getItem('select_post'); + return lastPost; } + getEmptyDraft() { return {message: '', uploadsInProgress: [], previews: []}; } @@ -397,10 +553,31 @@ class PostStoreClass extends EventEmitter { }); } storeLatestUpdate(channelId, time) { - BrowserStore.setItem('latest_post_' + channelId, time); + if (!this.postsInfo.hasOwnProperty(channelId)) { + this.postsInfo[channelId] = {}; + } + this.postsInfo[channelId].latestPost = time; } getLatestUpdate(channelId) { - return BrowserStore.getItem('latest_post_' + channelId, 0); + if (this.postsInfo.hasOwnProperty(channelId) && this.postsInfo[channelId].hasOwnProperty('latestPost')) { + return this.postsInfo[channelId].latestPost; + } + + return 0; + } + getCommentCount(post) { + const posts = this.getAllPosts(post.channel_id).posts; + + let commentCount = 0; + for (const id in posts) { + if (posts.hasOwnProperty(id)) { + if (posts[id].root_id === post.id) { + commentCount += 1; + } + } + } + + return commentCount; } } @@ -410,20 +587,45 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECIEVED_POSTS: - PostStore.storePosts(action.id, makePostListNonNull(action.post_list)); + case ActionTypes.RECIEVED_POSTS: { + const id = PostStore.currentFocusedPostId == null ? action.id : PostStore.currentFocusedPostId; + PostStore.checkBounds(id, action.numRequested, makePostListNonNull(action.post_list), action.before); + PostStore.storePosts(id, makePostListNonNull(action.post_list)); + PostStore.emitChange(); + break; + } + case ActionTypes.RECIEVED_FOCUSED_POST: + PostStore.clearChannelVisibility(action.postId, false); + PostStore.storeFocusedPost(action.postId, makePostListNonNull(action.post_list)); + PostStore.emitChange(); break; case ActionTypes.RECIEVED_POST: - PostStore.pStorePost(action.post); + PostStore.storePost(action.post); + PostStore.emitChange(); + break; + case ActionTypes.RECIEVED_EDIT_POST: + PostStore.emitEditPost(action); + PostStore.emitChange(); + break; + case ActionTypes.CLICK_CHANNEL: + PostStore.clearFocusedPost(); + PostStore.clearChannelVisibility(action.id, true); + PostStore.clearUnseenDeletedPosts(action.id); + break; + case ActionTypes.CREATE_POST: + PostStore.storePendingPost(action.post); + PostStore.storeDraft(action.post.channel_id, null); + PostStore.jumpPostsViewToBottom(); + break; + case ActionTypes.POST_DELETED: + PostStore.storeUnseenDeletedPost(action.post); + PostStore.removePost(action.post); PostStore.emitChange(); break; case ActionTypes.RECIEVED_POST_SELECTED: PostStore.storeSelectedPost(action.post_list); PostStore.emitSelectedPostChange(action.from_search); break; - case ActionTypes.RECIEVED_EDIT_POST: - PostStore.emitEditPost(action); - break; default: } }); diff --git a/web/react/stores/preference_store.jsx b/web/react/stores/preference_store.jsx index f630d150d..543129aca 100644 --- a/web/react/stores/preference_store.jsx +++ b/web/react/stores/preference_store.jsx @@ -1,11 +1,12 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const ActionTypes = require('../utils/constants.jsx').ActionTypes; -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const BrowserStore = require('./browser_store.jsx'); -const EventEmitter = require('events').EventEmitter; -const UserStore = require('../stores/user_store.jsx'); +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import BrowserStore from './browser_store.jsx'; +import EventEmitter from 'events'; +import UserStore from '../stores/user_store.jsx'; const CHANGE_EVENT = 'change'; @@ -89,8 +90,8 @@ class PreferenceStoreClass extends EventEmitter { return preference; } - emitChange(preferences) { - this.emit(CHANGE_EVENT, preferences); + emitChange() { + this.emit(CHANGE_EVENT); } addChangeListener(callback) { @@ -105,7 +106,13 @@ class PreferenceStoreClass extends EventEmitter { const action = payload.action; switch (action.type) { - case ActionTypes.RECIEVED_PREFERENCES: + case ActionTypes.RECIEVED_PREFERENCE: { + const preference = action.preference; + this.setPreference(preference.category, preference.name, preference.value); + this.emitChange(); + break; + } + case ActionTypes.RECIEVED_PREFERENCES: { const preferences = this.getAllPreferences(); for (const preference of action.preferences) { @@ -113,7 +120,9 @@ class PreferenceStoreClass extends EventEmitter { } this.setAllPreferences(preferences); - this.emitChange(preferences); + this.emitChange(); + break; + } } } } diff --git a/web/react/stores/search_store.jsx b/web/react/stores/search_store.jsx index 95f0ea845..f932c379a 100644 --- a/web/react/stores/search_store.jsx +++ b/web/react/stores/search_store.jsx @@ -1,19 +1,18 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var BrowserStore = require('../stores/browser_store.jsx'); +import BrowserStore from '../stores/browser_store.jsx'; -var Constants = require('../utils/constants.jsx'); +import Constants from '../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'; +var SHOW_SEARCH_EVENT = 'show_search'; class SearchStoreClass extends EventEmitter { constructor() { @@ -31,9 +30,9 @@ class SearchStoreClass extends EventEmitter { 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.emitShowSearch = this.emitShowSearch.bind(this); + this.addShowSearchListener = this.addShowSearchListener.bind(this); + this.removeShowSearchListener = this.removeShowSearchListener.bind(this); this.getSearchResults = this.getSearchResults.bind(this); this.getIsMentionSearch = this.getIsMentionSearch.bind(this); @@ -80,6 +79,18 @@ class SearchStoreClass extends EventEmitter { this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback); } + emitShowSearch() { + this.emit(SHOW_SEARCH_EVENT); + } + + addShowSearchListener(callback) { + this.on(SHOW_SEARCH_EVENT, callback); + } + + removeShowSearchListener(callback) { + this.removeListener(SHOW_SEARCH_EVENT, callback); + } + getSearchResults() { return BrowserStore.getItem('search_results'); } @@ -96,30 +107,6 @@ class SearchStoreClass extends EventEmitter { 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)); @@ -140,11 +127,8 @@ SearchStore.dispatchToken = AppDispatcher.register((payload) => { 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); + case ActionTypes.SHOW_SEARCH: + SearchStore.emitShowSearch(); break; default: } diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 4efeb7c8f..24fa79ca6 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -1,19 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -const UserStore = require('./user_store.jsx'); -const PostStore = require('./post_store.jsx'); -const ChannelStore = require('./channel_store.jsx'); -const BrowserStore = require('./browser_store.jsx'); -const ErrorStore = require('./error_store.jsx'); -const EventEmitter = require('events').EventEmitter; - -const Utils = require('../utils/utils.jsx'); -const AsyncClient = require('../utils/async_client.jsx'); - -const Constants = require('../utils/constants.jsx'); -const ActionTypes = Constants.ActionTypes; +import UserStore from './user_store.jsx'; +import PostStore from './post_store.jsx'; +import ChannelStore from './channel_store.jsx'; +import BrowserStore from './browser_store.jsx'; +import ErrorStore from './error_store.jsx'; +import EventEmitter from 'events'; + +import * as Utils from '../utils/utils.jsx'; +import * as AsyncClient from '../utils/async_client.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; + +import Constants from '../utils/constants.jsx'; const SocketEvents = Constants.SocketEvents; const CHANGE_EVENT = 'change'; @@ -60,13 +59,14 @@ class SocketStoreClass extends EventEmitter { conn.onopen = () => { if (this.failCount > 0) { console.log('websocket re-established connection'); //eslint-disable-line no-console + + if (ErrorStore.getLastError()) { + ErrorStore.storeLastError(null); + ErrorStore.emitChange(); + } } this.failCount = 0; - if (ErrorStore.getLastError()) { - ErrorStore.storeLastError(null); - ErrorStore.emitChange(); - } }; conn.onclose = () => { @@ -91,10 +91,9 @@ class SocketStoreClass extends EventEmitter { }; conn.onmessage = (evt) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_MSG, - msg: JSON.parse(evt.data) - }); + const msg = JSON.parse(evt.data); + this.handleMessage(msg); + this.emitChange(msg); }; } } @@ -137,6 +136,10 @@ class SocketStoreClass extends EventEmitter { handleChannelViewedEvent(msg); break; + case SocketEvents.PREFERENCE_CHANGED: + handlePreferenceChangedEvent(msg); + break; + default: } } @@ -153,19 +156,19 @@ class SocketStoreClass extends EventEmitter { function handleNewPostEvent(msg) { // Store post const post = JSON.parse(msg.props.post); - PostStore.storePost(post); + EventHelpers.emitPostRecievedEvent(post); // Update channel state if (ChannelStore.getCurrentId() === msg.channel_id) { if (window.isActive) { - AsyncClient.updateLastViewedAt(true); + AsyncClient.updateLastViewedAt(); } } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) { AsyncClient.getChannel(msg.channel_id); } // Send desktop notification - if (UserStore.getCurrentId() !== msg.user_id || post.props.from_webhook === 'true') { + if ((UserStore.getCurrentId() !== msg.user_id || post.props.from_webhook === 'true') && !Utils.isSystemMessage(post)) { const msgProps = msg.props; let mentions = []; @@ -226,6 +229,7 @@ function handlePostEditEvent(msg) { // Store post const post = JSON.parse(msg.props.post); PostStore.storePost(post); + PostStore.emitChange(); // Update channel state if (ChannelStore.getCurrentId() === msg.channel_id) { @@ -237,20 +241,17 @@ function handlePostEditEvent(msg) { function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.props.post); - - PostStore.storeUnseenDeletedPost(post); - PostStore.removePost(post, true); - PostStore.emitChange(); + EventHelpers.emitPostDeletedEvent(post); } function handleNewUserEvent() { AsyncClient.getProfiles(); - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } function handleUserAddedEvent(msg) { if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } if (UserStore.getCurrentId() === msg.user_id) { @@ -273,7 +274,7 @@ function handleUserRemovedEvent(msg) { $('#removed_from_channel').modal('show'); } } else if (ChannelStore.getCurrentId() === msg.channel_id) { - AsyncClient.getChannelExtraInfo(true); + AsyncClient.getChannelExtraInfo(); } } @@ -284,19 +285,19 @@ function handleChannelViewedEvent(msg) { } } +function handlePreferenceChangedEvent(msg) { + const preference = JSON.parse(msg.props.preference); + EventHelpers.emitPreferenceChangedEvent(preference); +} + var SocketStore = new SocketStoreClass(); -SocketStore.dispatchToken = AppDispatcher.register((payload) => { +/*SocketStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECIEVED_MSG: - SocketStore.handleMessage(action.msg); - SocketStore.emitChange(action.msg); - break; - default: } -}); + });*/ export default SocketStore; diff --git a/web/react/stores/suggestion_store.jsx b/web/react/stores/suggestion_store.jsx new file mode 100644 index 000000000..9cd566c22 --- /dev/null +++ b/web/react/stores/suggestion_store.jsx @@ -0,0 +1,259 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; +import EventEmitter from 'events'; + +const ActionTypes = Constants.ActionTypes; + +const COMPLETE_WORD_EVENT = 'complete_word'; +const PRETEXT_CHANGED_EVENT = 'pretext_changed'; +const SUGGESTIONS_CHANGED_EVENT = 'suggestions_changed'; + +class SuggestionStore extends EventEmitter { + constructor() { + super(); + + this.addSuggestionsChangedListener = this.addSuggestionsChangedListener.bind(this); + this.removeSuggestionsChangedListener = this.removeSuggestionsChangedListener.bind(this); + this.emitSuggestionsChanged = this.emitSuggestionsChanged.bind(this); + + this.addPretextChangedListener = this.addPretextChangedListener.bind(this); + this.removePretextChangedListener = this.removePretextChangedListener.bind(this); + this.emitPretextChanged = this.emitPretextChanged.bind(this); + + this.addCompleteWordListener = this.addCompleteWordListener.bind(this); + this.removeCompleteWordListener = this.removeCompleteWordListener.bind(this); + this.emitCompleteWord = this.emitCompleteWord.bind(this); + + this.handleEventPayload = this.handleEventPayload.bind(this); + this.dispatchToken = AppDispatcher.register(this.handleEventPayload); + + // this.suggestions stores the state of all SuggestionBoxes by mapping their unique identifier to an + // object with the following fields: + // pretext: the text before the cursor + // matchedPretext: the text before the cursor that will be replaced if an autocomplete term is selected + // terms: a list of strings which the previously typed text may be replaced by + // items: a list of objects backing the terms which may be used in rendering + // components: a list of react components that can be used to render their corresponding item + // selection: the term currently selected by the keyboard + this.suggestions = new Map(); + } + + addSuggestionsChangedListener(id, callback) { + this.on(SUGGESTIONS_CHANGED_EVENT + id, callback); + } + removeSuggestionsChangedListener(id, callback) { + this.removeListener(SUGGESTIONS_CHANGED_EVENT + id, callback); + } + emitSuggestionsChanged(id) { + this.emit(SUGGESTIONS_CHANGED_EVENT + id); + } + + addPretextChangedListener(id, callback) { + this.on(PRETEXT_CHANGED_EVENT + id, callback); + } + removePretextChangedListener(id, callback) { + this.removeListener(PRETEXT_CHANGED_EVENT + id, callback); + } + emitPretextChanged(id, pretext) { + this.emit(PRETEXT_CHANGED_EVENT + id, pretext); + } + + addCompleteWordListener(id, callback) { + this.on(COMPLETE_WORD_EVENT + id, callback); + } + removeCompleteWordListener(id, callback) { + this.removeListener(COMPLETE_WORD_EVENT + id, callback); + } + emitCompleteWord(id, term) { + this.emit(COMPLETE_WORD_EVENT + id, term); + } + + registerSuggestionBox(id) { + this.suggestions.set(id, { + pretext: '', + matchedPretext: '', + terms: [], + items: [], + components: [], + selection: '' + }); + } + + unregisterSuggestionBox(id) { + this.suggestions.delete(id); + } + + clearSuggestions(id) { + const suggestion = this.suggestions.get(id); + + suggestion.matchedPretext = ''; + suggestion.terms = []; + suggestion.items = []; + suggestion.components = []; + } + + clearSelection(id) { + const suggestion = this.suggestions.get(id); + + suggestion.selection = ''; + } + + hasSuggestions(id) { + return this.suggestions.get(id).terms.length > 0; + } + + setPretext(id, pretext) { + const suggestion = this.suggestions.get(id); + + suggestion.pretext = pretext; + } + + setMatchedPretext(id, matchedPretext) { + const suggestion = this.suggestions.get(id); + + suggestion.matchedPretext = matchedPretext; + } + + addSuggestion(id, term, item, component) { + const suggestion = this.suggestions.get(id); + + suggestion.terms.push(term); + suggestion.items.push(item); + suggestion.components.push(component); + } + + addSuggestions(id, terms, items, component) { + const suggestion = this.suggestions.get(id); + + suggestion.terms.push(...terms); + suggestion.items.push(...items); + + for (let i = 0; i < terms.length; i++) { + suggestion.components.push(component); + } + } + + // make sure that if suggestions exist, then one of them is selected. return true if the selection changes. + ensureSelectionExists(id) { + const suggestion = this.suggestions.get(id); + + if (suggestion.terms.length > 0) { + // if the current selection is no longer in the map, select the first term in the list + if (!suggestion.selection || suggestion.terms.indexOf(suggestion.selection) === -1) { + suggestion.selection = suggestion.terms[0]; + + return true; + } + } else if (suggestion.selection) { + suggestion.selection = ''; + + return true; + } + + return false; + } + + getPretext(id) { + return this.suggestions.get(id).pretext; + } + + getMatchedPretext(id) { + return this.suggestions.get(id).matchedPretext; + } + + getItems(id) { + return this.suggestions.get(id).items; + } + + getTerms(id) { + return this.suggestions.get(id).terms; + } + + getComponents(id) { + return this.suggestions.get(id).components; + } + + getSelection(id) { + return this.suggestions.get(id).selection; + } + + selectNext(id) { + this.setSelectionByDelta(id, 1); + } + + selectPrevious(id) { + this.setSelectionByDelta(id, -1); + } + + setSelectionByDelta(id, delta) { + const suggestion = this.suggestions.get(id); + + let selectionIndex = suggestion.terms.indexOf(suggestion.selection); + + if (selectionIndex === -1) { + // this should never happen since selection should always be in terms + throw new Error('selection is not in terms'); + } + + selectionIndex += delta; + + if (selectionIndex < 0) { + selectionIndex = 0; + } else if (selectionIndex > suggestion.terms.length - 1) { + selectionIndex = suggestion.terms.length - 1; + } + + suggestion.selection = suggestion.terms[selectionIndex]; + } + + handleEventPayload(payload) { + const {type, id, ...other} = payload.action; // eslint-disable-line no-redeclare + + switch (type) { + case ActionTypes.SUGGESTION_PRETEXT_CHANGED: + this.clearSuggestions(id); + + this.setPretext(id, other.pretext); + this.emitPretextChanged(id, other.pretext); + + this.ensureSelectionExists(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS: + if (other.matchedPretext === this.getMatchedPretext(id)) { + // ensure the matched pretext hasn't changed so that we don't receive suggestions for outdated pretext + this.addSuggestions(id, other.terms, other.items, other.component); + + this.ensureSelectionExists(id); + this.emitSuggestionsChanged(id); + } + break; + case ActionTypes.SUGGESTION_CLEAR_SUGGESTIONS: + this.clearSuggestions(id); + this.clearSelection(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_SELECT_NEXT: + this.selectNext(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_SELECT_PREVIOUS: + this.selectPrevious(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_COMPLETE_WORD: + this.emitCompleteWord(id, other.term || this.getSelection(id), this.getMatchedPretext(id)); + + this.setPretext(id, ''); + this.clearSuggestions(id); + this.clearSelection(id); + this.emitSuggestionsChanged(id); + break; + } + } +} + +export default new SuggestionStore(); diff --git a/web/react/stores/team_store.jsx b/web/react/stores/team_store.jsx index 22114ae85..2d518d9e7 100644 --- a/web/react/stores/team_store.jsx +++ b/web/react/stores/team_store.jsx @@ -1,14 +1,14 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; -var BrowserStore = require('../stores/browser_store.jsx'); +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import BrowserStore from '../stores/browser_store.jsx'; -var CHANGE_EVENT = 'change'; +const CHANGE_EVENT = 'change'; var Utils; function getWindowLocationOrigin() { @@ -31,6 +31,7 @@ class TeamStoreClass extends EventEmitter { this.getCurrentId = this.getCurrentId.bind(this); this.getCurrent = this.getCurrent.bind(this); this.getCurrentTeamUrl = this.getCurrentTeamUrl.bind(this); + this.getCurrentInviteLink = this.getCurrentInviteLink.bind(this); this.saveTeam = this.saveTeam.bind(this); } @@ -92,6 +93,16 @@ class TeamStoreClass extends EventEmitter { return null; } + getCurrentInviteLink() { + const current = this.getCurrent(); + + if (current) { + return getWindowLocationOrigin() + '/signup_user_complete/?id=' + current.invite_id; + } + + return ''; + } + saveTeam(team) { var teams = this.getAll(); teams[team.id] = team; diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index b173c9ca0..3e1871180 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -1,18 +1,18 @@ // 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; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; -var BrowserStore = require('./browser_store.jsx'); +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; +import BrowserStore from './browser_store.jsx'; -var CHANGE_EVENT = 'change'; -var CHANGE_EVENT_SESSIONS = 'change_sessions'; -var CHANGE_EVENT_AUDITS = 'change_audits'; -var CHANGE_EVENT_TEAMS = 'change_teams'; -var CHANGE_EVENT_STATUSES = 'change_statuses'; +const CHANGE_EVENT = 'change'; +const CHANGE_EVENT_SESSIONS = 'change_sessions'; +const CHANGE_EVENT_AUDITS = 'change_audits'; +const CHANGE_EVENT_TEAMS = 'change_teams'; +const CHANGE_EVENT_STATUSES = 'change_statuses'; class UserStoreClass extends EventEmitter { constructor() { @@ -164,6 +164,10 @@ class UserStoreClass extends EventEmitter { } getProfile(userId) { + if (userId === this.getCurrentId()) { + return this.getCurrentUser(); + } + return this.getProfiles()[userId]; } @@ -350,5 +354,4 @@ UserStore.dispatchToken = AppDispatcher.register((payload) => { } }); -global.window.UserStore = UserStore; -export default UserStore; +export {UserStore as default}; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 205c7461c..88b5aa739 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -1,15 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var client = require('./client.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var PostStore = require('../stores/post_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var utils = require('./utils.jsx'); - -var Constants = require('./constants.jsx'); +import * as client from './client.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import BrowserStore from '../stores/browser_store.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import PostStore from '../stores/post_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import * as utils from './utils.jsx'; + +import Constants from './constants.jsx'; var ActionTypes = Constants.ActionTypes; // Used to track in progress async calls @@ -40,88 +40,46 @@ function isCallInProgress(callName) { return true; } -export function getChannels(force, updateLastViewed, checkVersion) { - var channels = ChannelStore.getAll(); - - if (channels.length === 0 || force) { - if (isCallInProgress('getChannels')) { - return; - } +export function getChannels(checkVersion) { + if (isCallInProgress('getChannels')) { + return; + } - callTracker.getChannels = utils.getTimestamp(); + callTracker.getChannels = utils.getTimestamp(); - client.getChannels( - (data, textStatus, xhr) => { - callTracker.getChannels = 0; + client.getChannels( + (data, textStatus, xhr) => { + callTracker.getChannels = 0; - if (checkVersion) { - var serverVersion = xhr.getResponseHeader('X-Version-ID'); + if (checkVersion) { + var serverVersion = xhr.getResponseHeader('X-Version-ID'); - if (!BrowserStore.getLastServerVersion()) { + if (serverVersion !== BrowserStore.getLastServerVersion()) { + if (!BrowserStore.getLastServerVersion() || BrowserStore.getLastServerVersion() === '') { BrowserStore.setLastServerVersion(serverVersion); - } - - if (serverVersion !== BrowserStore.getLastServerVersion()) { + } else { BrowserStore.setLastServerVersion(serverVersion); - window.location.href = window.location.href; + window.location.reload(true); console.log('Detected version update refreshing the page'); //eslint-disable-line no-console } } - - if (xhr.status === 304 || !data) { - return; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_CHANNELS, - channels: data.channels, - members: data.members - }); - }, - (err) => { - callTracker.getChannels = 0; - dispatchError(err, 'getChannels'); } - ); - } else { - if (isCallInProgress('getChannelCounts')) { - return; - } - - callTracker.getChannelCounts = utils.getTimestamp(); - - client.getChannelCounts( - function getChannelCountsSuccess(data, textStatus, xhr) { - callTracker.getChannelCounts = 0; - if (xhr.status === 304 || !data) { - return; - } - - var countMap = data.counts; - var updateAtMap = data.update_times; - - for (var id in countMap) { - if ({}.hasOwnProperty.call(countMap, id)) { - var c = ChannelStore.get(id); - var count = countMap[id]; - var updateAt = updateAtMap[id]; - if (!c || c.total_msg_count !== count || updateAt > c.update_at) { - getChannel(id); - } - } - } - }, - function getChannelCountsFailure(err) { - callTracker.getChannelCounts = 0; - dispatchError(err, 'getChannelCounts'); + if (xhr.status === 304 || !data) { + return; } - ); - } - if (updateLastViewed && ChannelStore.getCurrentId() != null) { - updateLastViewedAt(); - } + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_CHANNELS, + channels: data.channels, + members: data.members + }); + }, + (err) => { + callTracker.getChannels = 0; + dispatchError(err, 'getChannels'); + } + ); } export function getChannel(id) { @@ -152,14 +110,19 @@ export function getChannel(id) { ); } -export function updateLastViewedAt(force) { - const channelId = ChannelStore.getCurrentId(); +export function updateLastViewedAt(id) { + let channelId; + if (id) { + channelId = id; + } else { + channelId = ChannelStore.getCurrentId(); + } - if (channelId === null) { + if (channelId == null) { return; } - if (isCallInProgress(`updateLastViewed${channelId}`) && !force) { + if (isCallInProgress(`updateLastViewed${channelId}`)) { return; } @@ -205,40 +168,40 @@ export function getMoreChannels(force) { } } -export function getChannelExtraInfo(force) { - var channelId = ChannelStore.getCurrentId(); +export function getChannelExtraInfo(id) { + let channelId; + if (id) { + channelId = id; + } else { + channelId = ChannelStore.getCurrentId(); + } if (channelId != null) { if (isCallInProgress('getChannelExtraInfo_' + channelId)) { return; } - var minMembers = 0; - if (ChannelStore.getCurrent() && ChannelStore.getCurrent().type === 'D') { - minMembers = 1; - } - if (ChannelStore.getCurrentExtraInfo().members.length <= minMembers || force) { - callTracker['getChannelExtraInfo_' + channelId] = utils.getTimestamp(); - client.getChannelExtraInfo( - channelId, - function getChannelExtraInfoSuccess(data, textStatus, xhr) { - callTracker['getChannelExtraInfo_' + channelId] = 0; + callTracker['getChannelExtraInfo_' + channelId] = utils.getTimestamp(); - if (xhr.status === 304 || !data) { - return; - } + client.getChannelExtraInfo( + channelId, + (data, textStatus, xhr) => { + callTracker['getChannelExtraInfo_' + channelId] = 0; - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO, - extra_info: data - }); - }, - function getChannelExtraInfoFailure(err) { - callTracker['getChannelExtraInfo_' + channelId] = 0; - dispatchError(err, 'getChannelExtraInfo'); + if (xhr.status === 304 || !data) { + return; } - ); - } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_CHANNEL_EXTRA_INFO, + extra_info: data + }); + }, + (err) => { + callTracker['getChannelExtraInfo_' + channelId] = 0; + dispatchError(err, 'getChannelExtraInfo'); + } + ); } } @@ -457,89 +420,92 @@ export function search(terms) { ); } -export function getPostsPage(force, id, maxPosts) { - if (PostStore.getCurrentPosts() == null || force) { - var channelId = id; +export function getPostsPage(id, maxPosts) { + let channelId = id; + if (channelId == null) { + channelId = ChannelStore.getCurrentId(); if (channelId == null) { - channelId = ChannelStore.getCurrentId(); - } - - if (isCallInProgress('getPostsPage_' + channelId)) { return; } + } - var postList = PostStore.getCurrentPosts(); + if (isCallInProgress('getPostsPage_' + channelId)) { + return; + } - var max = maxPosts; - if (max == null) { - max = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS; - } + var postList = PostStore.getAllPosts(id); - // if we already have more than POST_CHUNK_SIZE posts, - // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, - // with a max at maxPosts - var numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); - if (postList && postList.order.length > 0) { - numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); - } + var max = maxPosts; + if (max == null) { + max = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS; + } - if (channelId != null) { - callTracker['getPostsPage_' + channelId] = utils.getTimestamp(); + // if we already have more than POST_CHUNK_SIZE posts, + // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, + // with a max at maxPosts + var numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); + if (postList && postList.order.length > 0) { + numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); + } - client.getPostsPage( - channelId, - 0, - numPosts, - function getPostsPageSuccess(data, textStatus, xhr) { - if (xhr.status === 304 || !data) { - return; - } + if (channelId != null) { + callTracker['getPostsPage_' + channelId] = utils.getTimestamp(); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_POSTS, - id: channelId, - post_list: data - }); - - getProfiles(); - }, - function getPostsPageFailure(err) { - dispatchError(err, 'getPostsPage'); - }, - function getPostsPageComplete() { - callTracker['getPostsPage_' + channelId] = 0; + client.getPostsPage( + channelId, + 0, + numPosts, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; } - ); - } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: true, + numRequested: numPosts, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsPage'); + }, + () => { + callTracker['getPostsPage_' + channelId] = 0; + } + ); } } export function getPosts(id) { - var channelId = id; + let channelId = id; if (channelId == null) { - if (ChannelStore.getCurrentId() == null) { + channelId = ChannelStore.getCurrentId(); + if (channelId == null) { return; } - channelId = ChannelStore.getCurrentId(); } if (isCallInProgress('getPosts_' + channelId)) { return; } - if (PostStore.getCurrentPosts() == null) { - getPostsPage(true, id, Constants.POST_CHUNK_SIZE); + if (PostStore.getAllPosts(channelId) == null) { + getPostsPage(channelId, Constants.POST_CHUNK_SIZE); return; } - var latestUpdate = PostStore.getLatestUpdate(channelId); + const latestUpdate = PostStore.getLatestUpdate(channelId); callTracker['getPosts_' + channelId] = utils.getTimestamp(); client.getPosts( channelId, latestUpdate, - function success(data, textStatus, xhr) { + (data, textStatus, xhr) => { if (xhr.status === 304 || !data) { return; } @@ -547,20 +513,100 @@ export function getPosts(id) { AppDispatcher.handleServerAction({ type: ActionTypes.RECIEVED_POSTS, id: channelId, + before: true, + numRequested: Constants.POST_CHUNK_SIZE, post_list: data }); getProfiles(); }, - function fail(err) { + (err) => { dispatchError(err, 'getPosts'); }, - function complete() { + () => { callTracker['getPosts_' + channelId] = 0; } ); } +export function getPostsBefore(postId, offset, numPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + if (isCallInProgress('getPostsBefore_' + channelId)) { + return; + } + + client.getPostsBefore( + channelId, + postId, + offset, + numPost, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: true, + numRequested: numPost, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsBefore'); + }, + () => { + callTracker['getPostsBefore_' + channelId] = 0; + } + ); +} + +export function getPostsAfter(postId, offset, numPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + if (isCallInProgress('getPostsAfter_' + channelId)) { + return; + } + + client.getPostsAfter( + channelId, + postId, + offset, + numPost, + (data, textStatus, xhr) => { + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_POSTS, + id: channelId, + before: false, + numRequested: numPost, + post_list: data + }); + + getProfiles(); + }, + (err) => { + dispatchError(err, 'getPostsAfter'); + }, + () => { + callTracker['getPostsAfter_' + channelId] = 0; + } + ); +} + export function getMe() { if (isCallInProgress('getMe')) { return; @@ -699,3 +745,27 @@ export function savePreferences(preferences, success, error) { } ); } + +export function getSuggestedCommands(command, suggestionId, component) { + client.executeCommand( + '', + command, + true, + (data) => { + // pull out the suggested commands from the returned data + const terms = data.suggestions.map((suggestion) => suggestion.suggestion); + + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: command, + terms, + items: data.suggestions, + component + }); + }, + (err) => { + dispatchError(err, 'getCommandSuggestions'); + } + ); +} diff --git a/web/react/utils/channel_intro_mssages.jsx b/web/react/utils/channel_intro_messages.jsx index f27e23a82..9685f94b0 100644 --- a/web/react/utils/channel_intro_mssages.jsx +++ b/web/react/utils/channel_intro_messages.jsx @@ -1,23 +1,25 @@ - // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Utils = require('./utils.jsx'); -const InviteMemberModal = require('../components/invite_member_modal.jsx'); -const UserProfile = require('../components/user_profile.jsx'); -const ChannelStore = require('../stores/channel_store.jsx'); -const Constants = require('../utils/constants.jsx'); -const TeamStore = require('../stores/team_store.jsx'); +import * as Utils from './utils.jsx'; +import ChannelInviteModal from '../components/channel_invite_modal.jsx'; +import EditChannelHeaderModal from '../components/edit_channel_header_modal.jsx'; +import ToggleModalButton from '../components/toggle_modal_button.jsx'; +import UserProfile from '../components/user_profile.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import Constants from '../utils/constants.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; -export function createChannelIntroMessage(channel, showInviteModal) { +export function createChannelIntroMessage(channel) { if (channel.type === 'D') { return createDMIntroMessage(channel); } else if (ChannelStore.isDefault(channel)) { return createDefaultIntroMessage(channel); } else if (channel.name === Constants.OFFTOPIC_CHANNEL) { - return createOffTopicIntroMessage(channel, showInviteModal); + return createOffTopicIntroMessage(channel); } else if (channel.type === 'O' || channel.type === 'P') { - return createStandardIntroMessage(channel, showInviteModal); + return createStandardIntroMessage(channel); } } @@ -49,17 +51,7 @@ export function createDMIntroMessage(channel) { {'This is the start of your direct message history with ' + teammateName + '.'}<br/> {'Direct messages and files shared here are not shown to people outside this area.'} </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} </div> ); } @@ -71,7 +63,7 @@ export function createDMIntroMessage(channel) { ); } -export function createOffTopicIntroMessage(channel, showInviteModal) { +export function createOffTopicIntroMessage(channel) { return ( <div className='channel-intro'> <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> @@ -79,23 +71,8 @@ export function createOffTopicIntroMessage(channel, showInviteModal) { {'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'} <br/> </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - <a - href='#' - onClick={showInviteModal} - > - <i className='fa fa-user-plus'></i>{'Invite others to this channel'} - </a> + {createSetHeaderButton(channel)} + {createInviteChannelMemberButton(channel, 'channel')} </div> ); } @@ -108,7 +85,7 @@ export function createDefaultIntroMessage(channel) { <a className='intro-links' href='#' - onClick={InviteMemberModal.show} + onClick={EventHelpers.showInviteMemberModal} > <i className='fa fa-user-plus'></i>{'Invite others to this team'} </a> @@ -118,10 +95,7 @@ export function createDefaultIntroMessage(channel) { <a className='intro-links' href='#' - data-toggle='modal' - data-target='#get_link' - data-title='Team Invite' - data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} + onClick={EventHelpers.showGetTeamInviteLinkModal} > <i className='fa fa-user-plus'></i>{'Invite others to this team'} </a> @@ -137,23 +111,13 @@ export function createDefaultIntroMessage(channel) { {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} </p> {inviteModalLink} - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> + {createSetHeaderButton(channel)} <br/> </div> ); } -export function createStandardIntroMessage(channel, showInviteModal) { +export function createStandardIntroMessage(channel) { var uiName = channel.display_name; var creatorName = ''; @@ -192,23 +156,32 @@ export function createStandardIntroMessage(channel, showInviteModal) { {memberMessage} <br/> </p> - <a - className='intro-links' - href='#' - data-toggle='modal' - data-target='#edit_channel' - data-header={channel.header} - data-title={channel.display_name} - data-channelid={channel.id} - > - <i className='fa fa-pencil'></i>{'Set a header'} - </a> - <a - href='#' - onClick={showInviteModal} - > - <i className='fa fa-user-plus'></i>{'Invite others to this ' + uiType} - </a> + {createSetHeaderButton(channel)} + {createInviteChannelMemberButton(channel, uiType)} </div> ); } + +function createInviteChannelMemberButton(channel, uiType) { + return ( + <ToggleModalButton + className='intro-links' + dialogType={ChannelInviteModal} + dialogProps={{channel}} + > + <i className='fa fa-user-plus'></i>{'Invite others to this ' + uiType} + </ToggleModalButton> + ); +} + +function createSetHeaderButton(channel) { + return ( + <ToggleModalButton + className='intro-links' + dialogType={EditChannelHeaderModal} + dialogProps={{channel}} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </ToggleModalButton> + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index d27fe16cf..5d02a8c88 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1,8 +1,8 @@ // See License.txt for license information. -var BrowserStore = require('../stores/browser_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var ErrorStore = require('../stores/error_store.jsx'); +import BrowserStore from '../stores/browser_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import ErrorStore from '../stores/error_store.jsx'; export function track(category, action, label, property, value) { global.window.analytics.track(action, {category, label, property, value}); @@ -590,7 +590,12 @@ export function updateChannel(channel, success, error) { track('api', 'api_channels_update'); } -export function updateChannelHeader(data, success, error) { +export function updateChannelHeader(channelId, header, success, error) { + const data = { + channel_id: channelId, + channel_header: header + }; + $.ajax({ url: '/api/v1/channels/update_header', dataType: 'json', @@ -820,7 +825,37 @@ export function getPosts(channelId, since, success, error, complete) { }); } -export function getPost(channelId, postId, success, error) { +export function getPostsBefore(channelId, post, offset, numPost, success, error, complete) { + $.ajax({ + url: '/api/v1/channels/' + channelId + '/post/' + post + '/before/' + offset + '/' + numPost, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostsBefore', xhr, status, err); + error(e); + }, + complete: complete + }); +} + +export function getPostsAfter(channelId, post, offset, numPost, success, error, complete) { + $.ajax({ + url: '/api/v1/channels/' + channelId + '/post/' + post + '/after/' + offset + '/' + numPost, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostsAfter', xhr, status, err); + error(e); + }, + complete: complete + }); +} + +export function getPost(channelId, postId, success, error, complete) { $.ajax({ cache: false, url: '/api/v1/channels/' + channelId + '/post/' + postId, @@ -831,7 +866,24 @@ export function getPost(channelId, postId, success, error) { error: function onError(xhr, status, err) { var e = handleError('getPost', xhr, status, err); error(e); - } + }, + complete + }); +} + +export function getPostById(postId, success, error, complete) { + $.ajax({ + cache: false, + url: '/api/v1/posts/' + postId, + dataType: 'json', + type: 'GET', + ifModified: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('getPostById', xhr, status, err); + error(e); + }, + complete }); } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 58ee8e2d2..d23c18b5d 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -1,24 +1,30 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var keyMirror = require('keymirror'); +import keyMirror from 'keymirror'; -module.exports = { +export default { ActionTypes: keyMirror({ RECIEVED_ERROR: null, CLICK_CHANNEL: null, CREATE_CHANNEL: null, LEAVE_CHANNEL: null, + CREATE_POST: null, + POST_DELETED: null, + RECIEVED_CHANNELS: null, RECIEVED_CHANNEL: null, RECIEVED_MORE_CHANNELS: null, RECIEVED_CHANNEL_EXTRA_INFO: null, + FOCUS_POST: null, RECIEVED_POSTS: null, + RECIEVED_FOCUSED_POST: null, RECIEVED_POST: null, RECIEVED_EDIT_POST: null, RECIEVED_SEARCH: null, + RECIEVED_SEARCH_TERM: null, RECIEVED_POST_SELECTED: null, RECIEVED_MENTION_DATA: null, RECIEVED_ADD_MENTION: null, @@ -29,6 +35,7 @@ module.exports = { RECIEVED_AUDITS: null, RECIEVED_TEAMS: null, RECIEVED_STATUSES: null, + RECIEVED_PREFERENCE: null, RECIEVED_PREFERENCES: null, RECIEVED_MSG: null, @@ -39,8 +46,20 @@ module.exports = { RECIEVED_LOGS: null, RECIEVED_ALL_TEAMS: null, + SHOW_SEARCH: null, + TOGGLE_IMPORT_THEME_MODAL: null, - TOGGLE_INVITE_MEMBER_MODAL: null + TOGGLE_INVITE_MEMBER_MODAL: null, + TOGGLE_DELETE_POST_MODAL: null, + TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null, + TOGGLE_REGISTER_APP_MODAL: null, + + SUGGESTION_PRETEXT_CHANGED: null, + SUGGESTION_RECEIVED_SUGGESTIONS: null, + SUGGESTION_CLEAR_SUGGESTIONS: null, + SUGGESTION_COMPLETE_WORD: null, + SUGGESTION_SELECT_NEXT: null, + SUGGESTION_SELECT_PREVIOUS: null }), PayloadSources: keyMirror({ @@ -56,10 +75,12 @@ module.exports = { NEW_USER: 'new_user', USER_ADDED: 'user_added', USER_REMOVED: 'user_removed', - TYPING: 'typing' + TYPING: 'typing', + PREFERENCE_CHANGED: 'preference_changed' }, - SPECIAL_MENTIONS: ['all', 'channel'], + //SPECIAL_MENTIONS: ['all', 'channel'], + SPECIAL_MENTIONS: ['channel'], CHARACTER_LIMIT: 4000, IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'], AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac'], @@ -96,10 +117,14 @@ module.exports = { EMAIL_SERVICE: 'email', POST_CHUNK_SIZE: 60, MAX_POST_CHUNKS: 3, + POST_FOCUS_CONTEXT_RADIUS: 10, POST_LOADING: 'loading', POST_FAILED: 'failed', POST_DELETED: 'deleted', - POST_TYPE_JOIN_LEAVE: 'join_leave', + POST_TYPE_JOIN_LEAVE: 'system_join_leave', + SYSTEM_MESSAGE_PREFIX: 'system_', + SYSTEM_MESSAGE_PROFILE_NAME: 'System', + SYSTEM_MESSAGE_PROFILE_IMAGE: '/static/images/logo_compact.png', RESERVED_TEAM_NAMES: [ 'www', 'web', @@ -126,6 +151,7 @@ module.exports = { ], MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], MAX_DMS: 20, + MAX_CHANNEL_POPOVER_COUNT: 100, DM_CHANNEL: 'D', OPEN_CHANNEL: 'O', PRIVATE_CHANNEL: 'P', @@ -333,6 +359,20 @@ module.exports = { } ], DEFAULT_CODE_THEME: 'github', + FONTS: { + 'Droid Serif': 'font--droid_serif', + 'Roboto Slab': 'font--roboto_slab', + Lora: 'font--lora', + Arvo: 'font--arvo', + 'Open Sans': 'font--open_sans', + Roboto: 'font--roboto', + 'PT Sans': 'font--pt_sans', + Lato: 'font--lato', + 'Source Sans Pro': 'font--source_sans_pro', + 'Exo 2': 'font--exo_2', + Ubuntu: 'font--ubuntu' + }, + DEFAULT_FONT: 'Open Sans', Preferences: { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', CATEGORY_DISPLAY_SETTINGS: 'display_settings', @@ -353,7 +393,8 @@ module.exports = { BACKSPACE: 8, ENTER: 13, ESCAPE: 27, - SPACE: 32 + SPACE: 32, + TAB: 9 }, HighlightedLanguages: { diff: 'Diff', @@ -387,5 +428,16 @@ module.exports = { }, NotificationPrefs: { MENTION: 'mention' + }, + FeatureTogglePrefix: 'feature_enabled_', + PRE_RELEASE_FEATURES: { + MARKDOWN_PREVIEW: { + label: 'markdown_preview', // github issue: https://github.com/mattermost/platform/pull/1389 + description: 'Show markdown preview option in message input box' + }, + EMBED_PREVIEW: { + label: 'embed_preview', + description: 'Show preview snippet of links below message' + } } }; diff --git a/web/react/utils/emoticons.jsx b/web/react/utils/emoticons.jsx index bb948b6dc..fa5177232 100644 --- a/web/react/utils/emoticons.jsx +++ b/web/react/utils/emoticons.jsx @@ -13,7 +13,6 @@ const emoticonPatterns = { rage: /(^|\s)(:-?[\[@])(?=$|\s)/g, // :@ frowning: /(^|\s)(:-?\()(?=$|\s)/g, // :( sob: /(^|\s)(:['’]-?\(|:'\(|:'\()(?=$|\s)/g, // :`( - kissing_heart: /(^|\s)(:-?\*)(?=$|\s)/g, // :* pensive: /(^|\s)(:-?\/)(?=$|\s)/g, // :/ confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s flushed: /(^|\s)(:-?\|)(?=$|\s)/g, // :| @@ -116,19 +115,19 @@ function initializeEmoticonMap() { const out = new Map(); for (let i = 0; i < emoticonNames.length; i++) { - out[emoticonNames[i]] = true; + out.set(emoticonNames[i], true); } return out; } -const emoticonMap = initializeEmoticonMap(); +export const emoticonMap = initializeEmoticonMap(); export function handleEmoticons(text, tokens) { let output = text; function replaceEmoticonWithToken(fullMatch, prefix, matchText, name) { - if (emoticonMap[name]) { + if (emoticonMap.has(name)) { const index = tokens.size; const alias = `MM_EMOTICON${index}`; @@ -154,6 +153,9 @@ export function handleEmoticons(text, tokens) { return output; } -function getImagePathForEmoticon(name) { - return `/static/images/emoji/${name}.png`; +export function getImagePathForEmoticon(name) { + if (name) { + return `/static/images/emoji/${name}.png`; + } + return `/static/images/emoji`; } diff --git a/web/react/utils/highlight.jsx b/web/react/utils/highlight.jsx deleted file mode 100644 index 68fef7930..000000000 --- a/web/react/utils/highlight.jsx +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -const highlightJs = require('highlight.js/lib/highlight.js'); -const highlightJsDiff = require('highlight.js/lib/languages/diff.js'); -const highlightJsApache = require('highlight.js/lib/languages/apache.js'); -const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js'); -const highlightJsHttp = require('highlight.js/lib/languages/http.js'); -const highlightJsJson = require('highlight.js/lib/languages/json.js'); -const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js'); -const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js'); -const highlightJsCss = require('highlight.js/lib/languages/css.js'); -const highlightJsNginx = require('highlight.js/lib/languages/nginx.js'); -const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js'); -const highlightJsPython = require('highlight.js/lib/languages/python.js'); -const highlightJsXml = require('highlight.js/lib/languages/xml.js'); -const highlightJsPerl = require('highlight.js/lib/languages/perl.js'); -const highlightJsBash = require('highlight.js/lib/languages/bash.js'); -const highlightJsPhp = require('highlight.js/lib/languages/php.js'); -const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js'); -const highlightJsCs = require('highlight.js/lib/languages/cs.js'); -const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); -const highlightJsSql = require('highlight.js/lib/languages/sql.js'); -const highlightJsGo = require('highlight.js/lib/languages/go.js'); -const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); -const highlightJsJava = require('highlight.js/lib/languages/java.js'); -const highlightJsIni = require('highlight.js/lib/languages/ini.js'); - -highlightJs.registerLanguage('diff', highlightJsDiff); -highlightJs.registerLanguage('apache', highlightJsApache); -highlightJs.registerLanguage('makefile', highlightJsMakefile); -highlightJs.registerLanguage('http', highlightJsHttp); -highlightJs.registerLanguage('json', highlightJsJson); -highlightJs.registerLanguage('markdown', highlightJsMarkdown); -highlightJs.registerLanguage('javascript', highlightJsJavascript); -highlightJs.registerLanguage('css', highlightJsCss); -highlightJs.registerLanguage('nginx', highlightJsNginx); -highlightJs.registerLanguage('objectivec', highlightJsObjectivec); -highlightJs.registerLanguage('python', highlightJsPython); -highlightJs.registerLanguage('xml', highlightJsXml); -highlightJs.registerLanguage('perl', highlightJsPerl); -highlightJs.registerLanguage('bash', highlightJsBash); -highlightJs.registerLanguage('php', highlightJsPhp); -highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); -highlightJs.registerLanguage('cs', highlightJsCs); -highlightJs.registerLanguage('cpp', highlightJsCpp); -highlightJs.registerLanguage('sql', highlightJsSql); -highlightJs.registerLanguage('go', highlightJsGo); -highlightJs.registerLanguage('ruby', highlightJsRuby); -highlightJs.registerLanguage('java', highlightJsJava); -highlightJs.registerLanguage('ini', highlightJsIni); diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 946f93078..826b87d08 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -1,14 +1,62 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -require('./highlight.jsx'); -const TextFormatting = require('./text_formatting.jsx'); -const Utils = require('./utils.jsx'); - -const highlightJs = require('highlight.js/lib/highlight.js'); -const marked = require('marked'); - -const HighlightedLanguages = require('../utils/constants.jsx').HighlightedLanguages; +import highlightJs from 'highlight.js/lib/highlight.js'; +import highlightJsDiff from 'highlight.js/lib/languages/diff.js'; +import highlightJsApache from 'highlight.js/lib/languages/apache.js'; +import highlightJsMakefile from 'highlight.js/lib/languages/makefile.js'; +import highlightJsHttp from 'highlight.js/lib/languages/http.js'; +import highlightJsJson from 'highlight.js/lib/languages/json.js'; +import highlightJsMarkdown from 'highlight.js/lib/languages/markdown.js'; +import highlightJsJavascript from 'highlight.js/lib/languages/javascript.js'; +import highlightJsCss from 'highlight.js/lib/languages/css.js'; +import highlightJsNginx from 'highlight.js/lib/languages/nginx.js'; +import highlightJsObjectivec from 'highlight.js/lib/languages/objectivec.js'; +import highlightJsPython from 'highlight.js/lib/languages/python.js'; +import highlightJsXml from 'highlight.js/lib/languages/xml.js'; +import highlightJsPerl from 'highlight.js/lib/languages/perl.js'; +import highlightJsBash from 'highlight.js/lib/languages/bash.js'; +import highlightJsPhp from 'highlight.js/lib/languages/php.js'; +import highlightJsCoffeescript from 'highlight.js/lib/languages/coffeescript.js'; +import highlightJsCs from 'highlight.js/lib/languages/cs.js'; +import highlightJsCpp from 'highlight.js/lib/languages/cpp.js'; +import highlightJsSql from 'highlight.js/lib/languages/sql.js'; +import highlightJsGo from 'highlight.js/lib/languages/go.js'; +import highlightJsRuby from 'highlight.js/lib/languages/ruby.js'; +import highlightJsJava from 'highlight.js/lib/languages/java.js'; +import highlightJsIni from 'highlight.js/lib/languages/ini.js'; + +highlightJs.registerLanguage('diff', highlightJsDiff); +highlightJs.registerLanguage('apache', highlightJsApache); +highlightJs.registerLanguage('makefile', highlightJsMakefile); +highlightJs.registerLanguage('http', highlightJsHttp); +highlightJs.registerLanguage('json', highlightJsJson); +highlightJs.registerLanguage('markdown', highlightJsMarkdown); +highlightJs.registerLanguage('javascript', highlightJsJavascript); +highlightJs.registerLanguage('css', highlightJsCss); +highlightJs.registerLanguage('nginx', highlightJsNginx); +highlightJs.registerLanguage('objectivec', highlightJsObjectivec); +highlightJs.registerLanguage('python', highlightJsPython); +highlightJs.registerLanguage('xml', highlightJsXml); +highlightJs.registerLanguage('perl', highlightJsPerl); +highlightJs.registerLanguage('bash', highlightJsBash); +highlightJs.registerLanguage('php', highlightJsPhp); +highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); +highlightJs.registerLanguage('cs', highlightJsCs); +highlightJs.registerLanguage('cpp', highlightJsCpp); +highlightJs.registerLanguage('sql', highlightJsSql); +highlightJs.registerLanguage('go', highlightJsGo); +highlightJs.registerLanguage('ruby', highlightJsRuby); +highlightJs.registerLanguage('java', highlightJsJava); +highlightJs.registerLanguage('ini', highlightJsIni); + +import * as TextFormatting from './text_formatting.jsx'; +import * as Utils from './utils.jsx'; + +import marked from 'marked'; + +import Constants from '../utils/constants.jsx'; +const HighlightedLanguages = Constants.HighlightedLanguages; function markdownImageLoaded(image) { image.style.height = 'auto'; @@ -21,13 +69,11 @@ class MattermostInlineLexer extends marked.InlineLexer { this.rules = Object.assign({}, this.rules); - // modified version of the regex that doesn't break up words in snake_case, - // allows for links starting with www, and allows links succounded by parentheses + // modified version of the regex that allows for links starting with www and those surrounded by parentheses // the original is /^[\s\S]+?(?=[\\<!\[_*`~]|https?:\/\/| {2,}\n|$)/ - this.rules.text = /^[\s\S]+?(?:[^\w\/](?=_)|(?=_\W|[\\<!\[*`~]|https?:\/\/|www\.|\(| {2,}\n|$))/; + this.rules.text = /^[\s\S]+?(?=[\\<!\[_*`~]|https?:\/\/|www\.|\(| {2,}\n|$)/; - // modified version of the regex that allows links starting with www and those surrounded - // by parentheses + // modified version of the regex that allows links starting with www and those surrounded by parentheses // the original is /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/ this.rules.url = /^(\(?(?:https?:\/\/|www\.)[^\s<.][^\s<]*[^<.,:;"'\]\s])/; @@ -62,23 +108,47 @@ class MattermostMarkdownRenderer extends marked.Renderer { this.formattingOptions = formattingOptions; } - code(code, language) { - let usedLanguage = language; + code(code, language, escaped) { + let usedLanguage = language || ''; + usedLanguage = usedLanguage.toLowerCase(); - if (String(usedLanguage).toLocaleLowerCase() === 'html') { + // treat html as xml to prevent injection attacks + if (usedLanguage === 'html') { usedLanguage = 'xml'; } - if (!usedLanguage || highlightJs.listLanguages().indexOf(usedLanguage) < 0) { - let parsed = super.code(code, usedLanguage); - return '<div class="post-body--code"><code class="hljs">' + TextFormatting.sanitizeHtml($(parsed).text()) + '</code></div>'; + if (HighlightedLanguages[usedLanguage]) { + const parsed = highlightJs.highlight(usedLanguage, code); + + return ( + '<div class="post-body--code">' + + '<span class="post-body--code__language">' + + HighlightedLanguages[usedLanguage] + + '</span>' + + '<pre>' + + '<code class="hljs">' + + parsed.value + + '</code>' + + '</pre>' + + '</div>' + ); + } else if (usedLanguage === 'tex' || usedLanguage === 'latex') { + try { + const html = katex.renderToString(TextFormatting.sanitizeHtml(code), {throwOnError: false, displayMode: true}); + + return '<div class="post-body--code tex">' + html + '</div>'; + } catch (e) { + // fall through if latex parsing fails and handle below + } } - let parsed = highlightJs.highlight(usedLanguage, code); - return '<div class="post-body--code">' + - '<span class="post-body--code__language">' + HighlightedLanguages[usedLanguage] + '</span>' + - '<code class="hljs">' + parsed.value + '</code>' + - '</div>'; + return ( + '<pre>' + + '<code class="hljs">' + + (escaped ? code : TextFormatting.sanitizeHtml(code)) + '\n' + + '</code>' + + '</pre>' + ); } br() { @@ -151,6 +221,16 @@ class MattermostMarkdownRenderer extends marked.Renderer { return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`; } + listitem(text) { + const taskListReg = /^\[([ |xX])\] /; + const isTaskList = taskListReg.exec(text); + + if (isTaskList) { + return `<li class="list-item--task-list">${'<input type="checkbox" disabled="disabled" ' + (isTaskList[1] === ' ' ? '' : 'checked="checked" ') + '/> '}${text.replace(taskListReg, '')}</li>`; + } + return `<li>${text}</li>`; + } + text(txt) { return TextFormatting.doFormatText(txt, this.formattingOptions); } @@ -289,78 +369,78 @@ class MattermostLexer extends marked.Lexer { // list cap = this.rules.list.exec(src); if (cap) { + src = src.substring(cap[0].length); const bull = cap[2]; - let l = cap[0].length; + + this.tokens.push({ + type: 'list_start', + ordered: bull.length > 1 + }); // Get each top-level item. cap = cap[0].match(this.rules.item); - if (cap.length > 1) { - src = src.substring(l); - - this.tokens.push({ - type: 'list_start', - ordered: bull.length > 1 - }); - - let next = false; - l = cap.length; - - for (let i = 0; i < l; i++) { - let item = cap[i]; - - // Remove the list item's bullet - // so it is seen as the next token. - let space = item.length; - item = item.replace(/^ *([*+-]|\d+\.) +/, ''); - - // Outdent whatever the - // list item contains. Hacky. - if (~item.indexOf('\n ')) { - space -= item.length; - item = this.options.pedantic ? item.replace(/^ {1,4}/gm, '') : item.replace(new RegExp('^ \{1,' + space + '\}', 'gm'), ''); - } + let next = false; + const l = cap.length; + let i = 0; + + for (; i < l; i++) { + let item = cap[i]; + + // Remove the list item's bullet + // so it is seen as the next token. + let space = item.length; + item = item.replace(/^ *([*+-]|\d+\.) +/, ''); + + // Outdent whatever the + // list item contains. Hacky. + if (~item.indexOf('\n ')) { + space -= item.length; + item = this.options.pedantic ? + item.replace(/^ {1,4}/gm, '') : + item.replace(new RegExp('^ {1,' + space + '}', 'gm'), ''); + } - // Determine whether the next list item belongs here. - // Backpedal if it does not belong in this list. - if (this.options.smartLists && i !== l - 1) { - const bullet = /(?:[*+-]|\d+\.)/; - const b = bullet.exec(cap[i + 1])[0]; - if (bull !== b && !(bull.length > 1 && b.length > 1)) { - src = cap.slice(i + 1).join('\n') + src; - i = l - 1; - } + // Determine whether the next list item belongs here. + // Backpedal if it does not belong in this list. + if (this.options.smartLists && i !== l - 1) { + const b = this.rules.bullet.exec(cap[i + 1])[0]; + if (bull !== b && !(bull.length > 1 && b.length > 1)) { + src = cap.slice(i + 1).join('\n') + src; + i = l - 1; } + } - // Determine whether item is loose or not. - // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ - // for discount behavior. - let loose = next || (/\n\n(?!\s*$)/).test(item); - if (i !== l - 1) { - next = item.charAt(item.length - 1) === '\n'; - if (!loose) { - loose = next; - } + // Determine whether item is loose or not. + // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ + // for discount behavior. + let loose = next || (/\n\n(?!\s*$)/).test(item); + if (i !== l - 1) { + next = item.charAt(item.length - 1) === '\n'; + if (!loose) { + loose = next; } - - this.tokens.push({ - type: loose ? 'loose_item_start' : 'list_item_start' - }); - - // Recurse. - this.token(item, false, bq); - - this.tokens.push({ - type: 'list_item_end' - }); } this.tokens.push({ - type: 'list_end' + type: loose ? + 'loose_item_start' : + 'list_item_start' }); - continue; + // Recurse. + this.token(item, false, bq); + + this.tokens.push({ + type: 'list_item_end' + }); } + + this.tokens.push({ + type: 'list_end' + }); + + continue; } // html diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 705d85cf6..f0bd46f9d 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -1,12 +1,12 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -const Autolinker = require('autolinker'); -const Constants = require('./constants.jsx'); -const Emoticons = require('./emoticons.jsx'); -const Markdown = require('./markdown.jsx'); -const UserStore = require('../stores/user_store.jsx'); -const Utils = require('./utils.jsx'); +import Autolinker from 'autolinker'; +import Constants from './constants.jsx'; +import * as Emoticons from './emoticons.jsx'; +import * as Markdown from './markdown.jsx'; +import UserStore from '../stores/user_store.jsx'; +import * as Utils from './utils.jsx'; // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and // @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options @@ -188,7 +188,7 @@ function highlightCurrentMentions(text, tokens) { const newAlias = `MM_SELFMENTION${index}`; newTokens.set(newAlias, { - value: `<span class='mention-highlight'>${alias}</span>` + token.extraText, + value: `<span class='mention-highlight'>${alias}</span>` + (token.extraText || ''), originalText: token.originalText }); output = output.replace(alias, newAlias); @@ -259,30 +259,73 @@ function autolinkHashtags(text, tokens) { return output.replace(/(^|\W)(#[a-zA-Z][a-zA-Z0-9.\-_]*)\b/g, replaceHashtagWithToken); } -function highlightSearchTerm(text, tokens, searchTerm) { - let output = text; +const puncStart = /^[.,()&$!\[\]{}':;\\]+/; +const puncEnd = /[.,()&$#!\[\]{}':;\\]+$/; - var newTokens = new Map(); - for (const [alias, token] of tokens) { - if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) { - const index = tokens.size + newTokens.size; - const newAlias = `MM_SEARCHTERM${index}`; +function parseSearchTerms(searchTerm) { + let terms = []; - newTokens.set(newAlias, { - value: `<span class='search-highlight'>${alias}</span>`, - originalText: token.originalText - }); + let termString = searchTerm; - output = output.replace(alias, newAlias); + while (termString) { + let captured; + + // check for a quoted string + captured = (/^"(.*?)"/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + terms.push(captured[1]); + continue; + } + + // check for a search flag (and don't add it to terms) + captured = (/^(?:in|from|channel): ?\S+/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + continue; + } + + // capture any plain text up until the next quote or search flag + captured = (/^.+?(?=\bin|\bfrom|\bchannel|"|$)/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + + // break the text up into words based on how the server splits them in SqlPostStore.SearchPosts and then discard empty terms + terms.push(...captured[0].split(/[ <>+\-\(\)\~\@]/).filter((term) => !!term)); + continue; } + + // we should never reach this point since at least one of the regexes should match something in the remaining text + throw new Error('Infinite loop in search term parsing: ' + termString); } - // the new tokens are stashed in a separate map since we can't add objects to a map during iteration - for (const newToken of newTokens) { - tokens.set(newToken[0], newToken[1]); + // remove punctuation from each term + terms = terms.map((term) => term.replace(puncStart, '').replace(puncEnd, '')); + + return terms; +} + +function convertSearchTermToRegex(term) { + let pattern; + if (term.endsWith('*')) { + pattern = '\\b' + escapeRegex(term.substring(0, term.length - 1)); + } else { + pattern = '\\b' + escapeRegex(term) + '\\b'; } - function replaceSearchTermWithToken(fullMatch, prefix, word) { + return new RegExp(pattern, 'gi'); +} + +function highlightSearchTerm(text, tokens, searchTerm) { + const terms = parseSearchTerms(searchTerm); + + if (terms.length === 0) { + return text; + } + + let output = text; + + function replaceSearchTermWithToken(word) { const index = tokens.size; const alias = `MM_SEARCHTERM${index}`; @@ -291,10 +334,35 @@ function highlightSearchTerm(text, tokens, searchTerm) { originalText: word }); - return prefix + alias; + return alias; } - return output.replace(new RegExp(`()(${escapeRegex(searchTerm)})`, 'gi'), replaceSearchTermWithToken); + for (const term of terms) { + // highlight existing tokens matching search terms + var newTokens = new Map(); + for (const [alias, token] of tokens) { + if (token.originalText === term.replace(/\*$/, '')) { + const index = tokens.size + newTokens.size; + const newAlias = `MM_SEARCHTERM${index}`; + + newTokens.set(newAlias, { + value: `<span class='search-highlight'>${alias}</span>`, + originalText: token.originalText + }); + + output = output.replace(alias, newAlias); + } + } + + // the new tokens are stashed in a separate map since we can't add objects to a map during iteration + for (const newToken of newTokens) { + tokens.set(newToken[0], newToken[1]); + } + + output = output.replace(convertSearchTermToRegex(term), replaceSearchTermWithToken); + } + + return output; } function replaceTokens(text, tokens) { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 38f91b35f..fb8b89252 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -1,17 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var ChannelStore = require('../stores/channel_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var PreferenceStore = require('../stores/preference_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var Constants = require('../utils/constants.jsx'); +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import * as EventHelpers from '../dispatcher/event_helpers.jsx'; +import ChannelStore from '../stores/channel_store.jsx'; +import UserStore from '../stores/user_store.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import TeamStore from '../stores/team_store.jsx'; +import Constants from '../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'); +import * as Client from './client.jsx'; +import * as AsyncClient from './async_client.jsx'; +import * as client from './client.jsx'; +import Autolinker from 'autolinker'; export function isEmail(email) { //var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/; @@ -251,13 +252,6 @@ export function getTimestamp() { // extracts links not styled by Markdown export function extractLinks(text) { - const urlMatcher = new Autolinker.matchParser.MatchParser({ - urls: true, - emails: false, - twitter: false, - phone: false, - hashtag: false - }); const links = []; let replaceText = text; @@ -270,7 +264,7 @@ export function extractLinks(text) { } } - function replaceFn(match) { + function replaceFn(autolinker, match) { let link = ''; const matchText = match.getMatchedText(); const tempText = replaceText; @@ -303,7 +297,16 @@ export function extractLinks(text) { links.push(link); } - urlMatcher.replace(text, replaceFn, this); + + Autolinker.link(text, { + replaceFn, + urls: {schemeMatches: true, wwwMatches: true, tldMatches: false}, + emails: false, + twitter: false, + phone: false, + hashtag: false + }); + return {links, text}; } @@ -311,8 +314,98 @@ export function escapeRegExp(string) { return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); } -export function areStatesEqual(state1, state2) { - return JSON.stringify(state1) === JSON.stringify(state2); +// Taken from http://stackoverflow.com/questions/1068834/object-comparison-in-javascript and modified slightly +export function areObjectsEqual(x, y) { + let p; + const leftChain = []; + const rightChain = []; + + // Remember that NaN === NaN returns false + // and isNaN(undefined) returns true + if (isNaN(x) && isNaN(y) && typeof x === 'number' && typeof y === 'number') { + return true; + } + + // Compare primitives and functions. + // Check if both arguments link to the same object. + // Especially useful on step when comparing prototypes + if (x === y) { + return true; + } + + // Works in case when functions are created in constructor. + // Comparing dates is a common scenario. Another built-ins? + // We can even handle functions passed across iframes + if ((typeof x === 'function' && typeof y === 'function') || + (x instanceof Date && y instanceof Date) || + (x instanceof RegExp && y instanceof RegExp) || + (x instanceof String && y instanceof String) || + (x instanceof Number && y instanceof Number)) { + return x.toString() === y.toString(); + } + + // At last checking prototypes as good a we can + if (!(x instanceof Object && y instanceof Object)) { + return false; + } + + if (x.isPrototypeOf(y) || y.isPrototypeOf(x)) { + return false; + } + + if (x.constructor !== y.constructor) { + return false; + } + + if (x.prototype !== y.prototype) { + return false; + } + + // Check for infinitive linking loops + if (leftChain.indexOf(x) > -1 || rightChain.indexOf(y) > -1) { + return false; + } + + // Quick checking of one object beeing a subset of another. + for (p in y) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } else if (typeof y[p] !== typeof x[p]) { + return false; + } + } + + for (p in x) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } else if (typeof y[p] !== typeof x[p]) { + return false; + } + + switch (typeof (x[p])) { + case 'object': + case 'function': + + leftChain.push(x); + rightChain.push(y); + + if (!areObjectsEqual(x[p], y[p])) { + return false; + } + + leftChain.pop(); + rightChain.pop(); + break; + + default: + if (x[p] !== y[p]) { + return false; + } + break; + } + } + + return true; } export function replaceHtmlEntities(text) { @@ -451,7 +544,6 @@ export function applyTheme(theme) { changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li>a', 'color:' + theme.sidebarText, 1); changeCss('.sidebar--left .nav-pills__container li>h4, .sidebar--left .add-channel-btn', 'color:' + changeOpacity(theme.sidebarText, 0.6), 1); changeCss('.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + theme.sidebarText, 1); - changeCss('.sidebar--left, .sidebar--right .sidebar--right__header', 'border-color:' + changeOpacity(theme.sidebarText, 0.2), 1); changeCss('.sidebar--left .status path', 'fill:' + changeOpacity(theme.sidebarText, 0.5), 1); changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li>a', 'border-color:' + changeOpacity(theme.sidebarText, 0.2), 2); } @@ -472,8 +564,6 @@ export function applyTheme(theme) { if (theme.sidebarTextActiveColor) { changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'color:' + theme.sidebarTextActiveColor, 2); changeCss('.sidebar--left .nav li.active a, .sidebar--left .nav li.active a:hover, .sidebar--left .nav li.active a:focus', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.1), 1); - changeCss('.search-help-popover .search-autocomplete__item:hover', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.05), 1); - changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.15), 1); } if (theme.sidebarHeaderBg) { @@ -486,6 +576,7 @@ export function applyTheme(theme) { if (theme.sidebarHeaderTextColor) { changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info', 'color:' + theme.sidebarHeaderTextColor, 1); + changeCss('.sidebar--left .team__header .navbar-right .dropdown__icon, .sidebar--menu .team__header .navbar-right .dropdown__icon', 'fill:' + theme.sidebarHeaderTextColor, 1); changeCss('.sidebar--left .team__header .user__name, .sidebar--menu .team__header .user__name', 'color:' + changeOpacity(theme.sidebarHeaderTextColor, 0.8), 1); changeCss('.sidebar--left .team__header:hover .user__name, .sidebar--menu .team__header:hover .user__name', 'color:' + theme.sidebarHeaderTextColor, 1); changeCss('.modal .modal-header .modal-title, .modal .modal-header .modal-title .name, .modal .modal-header button.close', 'color:' + theme.sidebarHeaderTextColor, 1); @@ -509,7 +600,7 @@ export function applyTheme(theme) { } if (theme.centerChannelBg) { - changeCss('.app__content, .markdown__table, .markdown__table tbody tr, .command-box, .modal .modal-content, .mentions-name, .mentions--top .mentions-box', 'background:' + theme.centerChannelBg, 1); + changeCss('.app__content, .markdown__table, .markdown__table tbody tr, .suggestion-content, .modal .modal-content', 'background:' + theme.centerChannelBg, 1); changeCss('#post-list .post-list-holder-by-time', 'background:' + theme.centerChannelBg, 1); changeCss('#post-create', 'background:' + theme.centerChannelBg, 1); changeCss('.date-separator .separator__text, .new-separator .separator__text', 'background:' + theme.centerChannelBg, 1); @@ -519,27 +610,26 @@ export function applyTheme(theme) { changeCss('.popover.right>.arrow:after, .tip-overlay.tip-overlay--sidebar .arrow, .tip-overlay.tip-overlay--header .arrow', 'border-right-color:' + theme.centerChannelBg, 1); changeCss('.popover.left>.arrow:after', 'border-left-color:' + theme.centerChannelBg, 1); changeCss('.popover.top>.arrow:after, .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg, 1); - changeCss('.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1); + changeCss('@media(min-width: 768px){.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1); changeCss('.attachment__content', 'background:' + theme.centerChannelBg, 1); } if (theme.centerChannelColor) { + changeCss('.sidebar--left, .sidebar--right .sidebar--right__header', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name, .tip-overlay', 'color:' + theme.centerChannelColor, 1); changeCss('#post-create', 'color:' + theme.centerChannelColor, 2); - changeCss('.channel-header__links a', 'fill:' + changeOpacity(theme.centerChannelColor, 0.7), 1); - changeCss('.channel-header__links a:hover, .channel-header__links a:active', 'fill:' + theme.centerChannelColor, 2); - changeCss('.mentions--top, .command-box', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 3); - changeCss('.mentions--top, .command-box', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 2); - changeCss('.mentions--top, .command-box', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 1); + changeCss('.mentions--top, .suggestion-list', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 3); + changeCss('.mentions--top, .suggestion-list', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 2); + changeCss('.mentions--top, .suggestion-list', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 1); changeCss('.dropdown-menu, .popover ', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 3); changeCss('.dropdown-menu, .popover ', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 2); changeCss('.dropdown-menu, .popover ', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 1); - changeCss('.post-body hr, .loading-screen .loading__content .round, .tutorial__circles .circle', 'background:' + theme.centerChannelColor, 1); + changeCss('.post__body hr, .loading-screen .loading__content .round, .tutorial__circles .circle', 'background:' + theme.centerChannelColor, 1); changeCss('.channel-header .heading', 'color:' + theme.centerChannelColor, 1); changeCss('.markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); changeCss('.channel-header #member_popover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); - changeCss('.custom-textarea, .custom-textarea:focus, .preview-container .preview-div, .post-image__column .post-image__details, .sidebar--right .sidebar-right__body, .markdown__table th, .markdown__table td, .command-box, .modal .modal-content, .settings-modal .settings-table .settings-content .divider-light, .webhooks__container, .dropdown-menu, .modal .modal-header, .popover, .mentions--top .mentions-box', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); + changeCss('.custom-textarea, .custom-textarea:focus, .preview-container .preview-div, .post-image__column .post-image__details, .sidebar--right .sidebar-right__body, .markdown__table th, .markdown__table td, .suggestion-content, .modal .modal-content, .settings-modal .settings-table .settings-content .divider-light, .webhooks__container, .dropdown-menu, .modal .modal-header, .popover', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.popover.bottom>.arrow', 'border-bottom-color:' + changeOpacity(theme.centerChannelColor, 0.25), 1); changeCss('.search-help-popover .search-autocomplete__divider span', 'color:' + changeOpacity(theme.centerChannelColor, 0.7), 1); changeCss('.popover.right>.arrow', 'border-right-color:' + changeOpacity(theme.centerChannelColor, 0.25), 1); @@ -551,8 +641,7 @@ export function applyTheme(theme) { changeCss('.post-image__column', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 2); changeCss('.post-image__column .post-image__details', 'color:' + theme.centerChannelColor, 2); changeCss('.post-image__column a, .post-image__column a:hover, .post-image__column a:focus', 'color:' + theme.centerChannelColor, 1); - changeCss('.search-bar__container .search__form .search-bar, .form-control', 'color:' + theme.centerChannelColor, 2); - changeCss('@media(max-width: 768px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1); + changeCss('@media(min-width: 768px){.search-bar__container .search__form .search-bar, .form-control', 'color:' + theme.centerChannelColor, 2); changeCss('.input-group-addon, .search-bar__container .search__form, .form-control', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); @@ -561,17 +650,17 @@ export function applyTheme(theme) { changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.modal .custom-textarea:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.channel-intro, .settings-modal .settings-table .settings-content .divider-dark, hr, .settings-modal .settings-table .settings-links', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); - changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); - changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); - changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); - changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); + changeCss('.post.current--user .post__body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); + changeCss('.post.current--user .post__body, .post.post--comment.other--root.current--user .post-comment, .post.same--root.post--comment .post__body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); changeCss('@media(max-width: 1800px){.inner__wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); - changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .sidebar--right .sidebar--right__header, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); + changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.date-separator.hovered--before:after, .date-separator.hovered--after:before, .new-separator.hovered--after:before, .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); - changeCss('.command-name:hover, .mentions-name:hover, .mentions-focus, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover, .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); + changeCss('.command-name:hover, .mentions-name:hover, .suggestion--selected, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover, .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); changeCss('code', 'background:' + changeOpacity(theme.centerChannelColor, 0.1), 1); - changeCss('.post.current--user:hover .post-body ', 'background: none;', 1); + changeCss('@media(min-width: 960px){.post.current--user:hover .post__body ', 'background: none;', 1); changeCss('.sidebar--right', 'color:' + theme.centerChannelColor, 2); + changeCss('.search-help-popover .search-autocomplete__item:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1); + changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); } if (theme.newMessageSeparator) { @@ -595,7 +684,11 @@ export function applyTheme(theme) { } if (theme.mentionHighlightBg) { - changeCss('.mention-highlight, .search-highlight', 'background:' + theme.mentionHighlightBg, 1); + changeCss('.mention-highlight, .search-highlight, #archive-link-home', 'background:' + theme.mentionHighlightBg, 1); + } + + if (theme.mentionHighlightBg) { + changeCss('.post.post--highlight, #archive-link-home', 'background:' + changeOpacity(theme.mentionHighlightBg, 0.5), 1); } if (theme.mentionHighlightLink) { @@ -607,6 +700,23 @@ export function applyTheme(theme) { } updateCodeTheme(theme.codeTheme); } + +export function applyFont(fontName) { + const body = $('body'); + + for (const key of Reflect.ownKeys(Constants.FONTS)) { + const className = Constants.FONTS[key]; + + if (fontName === key) { + if (!body.hasClass(className)) { + body.addClass(className); + } + } else { + body.removeClass(className); + } + } +} + export function changeCss(className, classValue, classRepeat) { // we need invisible container to store additional css definitions var cssMainContainer = $('#css-modifier-container'); @@ -662,19 +772,10 @@ export function updateCodeTheme(theme) { export function placeCaretAtEnd(el) { el.focus(); - if (typeof window.getSelection != 'undefined' && typeof document.createRange != 'undefined') { - var range = document.createRange(); - range.selectNodeContents(el); - range.collapse(false); - var sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - } else if (typeof document.body.createTextRange != 'undefined') { - var textRange = document.body.createTextRange(); - textRange.moveToElementText(el); - textRange.collapse(false); - textRange.select(); - } + el.selectionStart = el.value.length; + el.selectionEnd = el.value.length; + + return; } export function getCaretPosition(el) { @@ -753,23 +854,15 @@ export function isValidUsername(name) { } export function updateAddressBar(channelName) { - var teamURL = window.location.href.split('/channels')[0]; + const teamURL = TeamStore.getCurrentTeamUrl(); history.replaceState('data', '', teamURL + '/channels/' + channelName); } export function switchChannel(channel) { - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - name: channel.name, - id: channel.id - }); + EventHelpers.emitChannelClickEvent(channel); updateAddressBar(channel.name); - AsyncClient.getChannels(true, true, true); - AsyncClient.getChannelExtraInfo(true); - AsyncClient.getPosts(channel.id); - $('.inner__wrap').removeClass('move--right'); $('.sidebar--left').removeClass('move--right'); @@ -784,7 +877,7 @@ export function isMobile() { export function isComment(post) { if ('root_id' in post) { - return post.root_id !== ''; + return post.root_id !== '' && post.root_id != null; } return false; } @@ -911,13 +1004,15 @@ export function displayUsername(userId) { const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value; let username = ''; - if (nameFormat === 'nickname_full_name') { - username = user.nickname || getFullName(user); - } else if (nameFormat === 'full_name') { - username = getFullName(user); - } - if (!username.trim().length) { - username = user.username; + if (user) { + if (nameFormat === 'nickname_full_name') { + username = user.nickname || getFullName(user); + } else if (nameFormat === 'full_name') { + username = getFullName(user); + } + if (!username.trim().length) { + username = user.username; + } } return username; @@ -1043,6 +1138,11 @@ export function getUserIdFromChannelName(channel) { return otherUserId; } +// Returns true if the given channel is a direct channel between the current user and the given one +export function isDirectChannelForUser(otherUserId, channel) { + return channel.type === Constants.DM_CHANNEL && getUserIdFromChannelName(channel) === otherUserId; +} + export function importSlack(file, success, error) { var formData = new FormData(); formData.append('file', file, file.name); @@ -1142,3 +1242,20 @@ export function getChannelTerm(channelType) { return channelTerm; } + +export function getPostTerm(post) { + let postTerm = 'Post'; + if (post.root_id) { + postTerm = 'Comment'; + } + + return postTerm; +} + +export function isFeatureEnabled(feature) { + return PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, Constants.FeatureTogglePrefix + feature.label, {value: 'false'}).value === 'true'; +} + +export function isSystemMessage(post) { + return post.type && (post.type.lastIndexOf(Constants.SYSTEM_MESSAGE_PREFIX) === 0); +} |