diff options
Diffstat (limited to 'webapp/components')
99 files changed, 1773 insertions, 1252 deletions
diff --git a/webapp/components/activity_log_modal.jsx b/webapp/components/activity_log_modal.jsx index b907668f0..cd369f742 100644 --- a/webapp/components/activity_log_modal.jsx +++ b/webapp/components/activity_log_modal.jsx @@ -5,7 +5,6 @@ import LoadingScreen from './loading_screen.jsx'; import UserStore from 'stores/user_store.jsx'; -import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -14,6 +13,8 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage, FormattedTime, FormattedDate} from 'react-intl'; +import {revokeSession} from 'actions/admin_actions.jsx'; + export default class ActivityLogModal extends React.Component { constructor(props) { super(props); @@ -46,10 +47,8 @@ export default class ActivityLogModal extends React.Component { setTimeout(() => { modalContent.removeClass('animation--highlight'); }, 1500); - Client.revokeSession(altId, - () => { - AsyncClient.getSessions(); - }, + revokeSession(altId, + null, (err) => { const state = this.getStateFromStores(); state.serverError = err; @@ -134,6 +133,17 @@ export default class ActivityLogModal extends React.Component { } else { devicePicture = 'fa fa-linux'; } + } else if (currentSession.props.os.indexOf('Linux') !== -1) { + devicePicture = 'fa fa-linux'; + } + + if (currentSession.props.browser.indexOf('Desktop App') !== -1) { + devicePlatform = ( + <FormattedMessage + id='activity_log_modal.desktop' + defaultMessage='Native Desktop App' + /> + ); } let moreInfo; diff --git a/webapp/components/admin_console/admin_settings.jsx b/webapp/components/admin_console/admin_settings.jsx index 9975a3975..b9883d7d8 100644 --- a/webapp/components/admin_console/admin_settings.jsx +++ b/webapp/components/admin_console/admin_settings.jsx @@ -4,11 +4,12 @@ import React from 'react'; import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import FormError from 'components/form_error.jsx'; import SaveButton from 'components/admin_console/save_button.jsx'; +import {saveConfig} from 'actions/admin_actions.jsx'; + export default class AdminSettings extends React.Component { static get propTypes() { return { @@ -53,7 +54,7 @@ export default class AdminSettings extends React.Component { let config = JSON.parse(JSON.stringify(this.props.config)); config = this.getConfigFromState(config); - Client.saveConfig( + saveConfig( config, () => { AsyncClient.getConfig((savedConfig) => { diff --git a/webapp/components/admin_console/admin_sidebar_header.jsx b/webapp/components/admin_console/admin_sidebar_header.jsx index 86c2c6b0f..5725551bf 100644 --- a/webapp/components/admin_console/admin_sidebar_header.jsx +++ b/webapp/components/admin_console/admin_sidebar_header.jsx @@ -42,7 +42,7 @@ export default class SidebarHeader extends React.Component { profilePicture = ( <img className='user__picture' - src={Client.getUsersRoute() + '/' + me.id + '/image?time=' + me.update_at} + src={Client.getUsersRoute() + '/' + me.id + '/image?time=' + me.last_picture_update} /> ); } diff --git a/webapp/components/admin_console/admin_team_members_dropdown.jsx b/webapp/components/admin_console/admin_team_members_dropdown.jsx index ee9e53f6c..01e94db16 100644 --- a/webapp/components/admin_console/admin_team_members_dropdown.jsx +++ b/webapp/components/admin_console/admin_team_members_dropdown.jsx @@ -6,12 +6,10 @@ import ConfirmModal from '../confirm_modal.jsx'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import {updateUserRoles, updateActive} from 'actions/user_actions.jsx'; -import {updateTeamMemberRoles} from 'actions/team_actions.jsx'; +import {updateTeamMemberRoles, removeUserFromTeam, adminResetMfa} from 'actions/team_actions.jsx'; import {FormattedMessage} from 'react-intl'; @@ -75,14 +73,10 @@ export default class AdminTeamMembersDropdown extends React.Component { } handleRemoveFromTeam() { - Client.removeUserFromTeam( + removeUserFromTeam( this.props.teamMember.team_id, this.props.user.id, - () => { - AsyncClient.getTeamStats(this.props.teamMember.team_id); - UserStore.removeProfileFromTeam(this.props.teamMember.team_id, this.props.user.id); - UserStore.emitInTeamChange(); - }, + null, (err) => { this.setState({serverError: err.message}); } @@ -150,10 +144,8 @@ export default class AdminTeamMembersDropdown extends React.Component { handleResetMfa(e) { e.preventDefault(); - Client.adminResetMfa(this.props.user.id, - () => { - AsyncClient.getUser(this.props.user.id); - }, + adminResetMfa(this.props.user.id, + null, (err) => { this.setState({serverError: err.message}); } diff --git a/webapp/components/admin_console/brand_image_setting.jsx b/webapp/components/admin_console/brand_image_setting.jsx index 653073200..b58c0159c 100644 --- a/webapp/components/admin_console/brand_image_setting.jsx +++ b/webapp/components/admin_console/brand_image_setting.jsx @@ -7,6 +7,7 @@ import ReactDOM from 'react-dom'; import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; +import {uploadBrandImage} from 'actions/admin_actions.jsx'; import FormError from 'components/form_error.jsx'; import {FormattedHTMLMessage, FormattedMessage} from 'react-intl'; @@ -81,7 +82,7 @@ export default class BrandImageSetting extends React.Component { error: '' }); - Client.uploadBrandImage( + uploadBrandImage( this.state.brandImage, () => { $(ReactDOM.findDOMNode(this.refs.upload)).button('complete'); diff --git a/webapp/components/admin_console/cluster_table_container.jsx b/webapp/components/admin_console/cluster_table_container.jsx index aad5753b7..8dba80cce 100644 --- a/webapp/components/admin_console/cluster_table_container.jsx +++ b/webapp/components/admin_console/cluster_table_container.jsx @@ -4,8 +4,8 @@ import React from 'react'; import ClusterTable from './cluster_table.jsx'; import LoadingScreen from '../loading_screen.jsx'; -import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; + +import {getClusterStatus} from 'actions/admin_actions.jsx'; export default class ClusterTableContainer extends React.Component { constructor(props) { @@ -19,15 +19,13 @@ export default class ClusterTableContainer extends React.Component { } load() { - Client.getClusterStatus( + getClusterStatus( (data) => { this.setState({ clusterInfos: data }); }, - (err) => { - AsyncClient.dispatchError(err, 'getClusterStatus'); - } + null ); } diff --git a/webapp/components/admin_console/compliance_reports.jsx b/webapp/components/admin_console/compliance_reports.jsx index aac09c0de..7274e6774 100644 --- a/webapp/components/admin_console/compliance_reports.jsx +++ b/webapp/components/admin_console/compliance_reports.jsx @@ -9,6 +9,7 @@ import UserStore from '../../stores/user_store.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from '../../utils/async_client.jsx'; +import {saveComplianceReports} from 'actions/admin_actions.jsx'; import {FormattedMessage, FormattedDate, FormattedTime} from 'react-intl'; @@ -72,7 +73,7 @@ export default class ComplianceReports extends React.Component { job.start_at = Date.parse(ReactDOM.findDOMNode(this.refs.from).value); job.end_at = Date.parse(ReactDOM.findDOMNode(this.refs.to).value); - Client.saveComplianceReports( + saveComplianceReports( job, () => { ReactDOM.findDOMNode(this.refs.emails).value = ''; diff --git a/webapp/components/admin_console/email_connection_test.jsx b/webapp/components/admin_console/email_connection_test.jsx index 8e11a0bb4..b99633eec 100644 --- a/webapp/components/admin_console/email_connection_test.jsx +++ b/webapp/components/admin_console/email_connection_test.jsx @@ -3,11 +3,12 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {FormattedMessage} from 'react-intl'; +import {testEmail} from 'actions/admin_actions.jsx'; + export default class EmailConnectionTestButton extends React.Component { static get propTypes() { return { @@ -41,7 +42,7 @@ export default class EmailConnectionTestButton extends React.Component { const config = JSON.parse(JSON.stringify(this.props.config)); this.props.getConfigFromState(config); - Client.testEmail( + testEmail( config, () => { this.setState({ diff --git a/webapp/components/admin_console/ldap_test_button.jsx b/webapp/components/admin_console/ldap_test_button.jsx index e077aec5f..a564fa42a 100644 --- a/webapp/components/admin_console/ldap_test_button.jsx +++ b/webapp/components/admin_console/ldap_test_button.jsx @@ -3,11 +3,12 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {ldapTest} from 'actions/admin_actions.jsx'; + export default class LdapTestButton extends React.Component { static get propTypes() { return { @@ -38,7 +39,7 @@ export default class LdapTestButton extends React.Component { }); const doRequest = () => { //eslint-disable-line func-style - Client.ldapTest( + ldapTest( () => { this.setState({ buisy: false, diff --git a/webapp/components/admin_console/license_settings.jsx b/webapp/components/admin_console/license_settings.jsx index d98309f80..6c14394b7 100644 --- a/webapp/components/admin_console/license_settings.jsx +++ b/webapp/components/admin_console/license_settings.jsx @@ -4,7 +4,8 @@ import $ from 'jquery'; import ReactDOM from 'react-dom'; import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; + +import {uploadLicenseFile, removeLicenseFile} from 'actions/admin_actions.jsx'; import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -54,7 +55,8 @@ class LicenseSettings extends React.Component { $('#upload-button').button('loading'); - Client.uploadLicenseFile(file, + uploadLicenseFile( + file, () => { Utils.clearFileInput(element[0]); $('#upload-button').button('reset'); @@ -74,7 +76,7 @@ class LicenseSettings extends React.Component { $('#remove-button').button('loading'); - Client.removeLicenseFile( + removeLicenseFile( () => { $('#remove-button').button('reset'); this.setState({fileSelected: false, fileName: null, serverError: null}); diff --git a/webapp/components/admin_console/logs.jsx b/webapp/components/admin_console/logs.jsx index 8dc0c1e2e..5846c91db 100644 --- a/webapp/components/admin_console/logs.jsx +++ b/webapp/components/admin_console/logs.jsx @@ -24,12 +24,14 @@ export default class Logs extends React.Component { componentDidMount() { AdminStore.addLogChangeListener(this.onLogListenerChange); AsyncClient.getLogs(); + this.refs.logPanel.focus(); } componentDidUpdate() { // Scroll Down to get the latest logs var node = this.refs.logPanel; node.scrollTop = node.scrollHeight; + node.focus(); } componentWillUnmount() { @@ -100,6 +102,7 @@ export default class Logs extends React.Component { /> </button> <div + tabIndex='-1' ref='logPanel' className='log__panel' > diff --git a/webapp/components/admin_console/policy_settings.jsx b/webapp/components/admin_console/policy_settings.jsx index 0e224af73..391726a93 100644 --- a/webapp/components/admin_console/policy_settings.jsx +++ b/webapp/components/admin_console/policy_settings.jsx @@ -6,6 +6,8 @@ import React from 'react'; import AdminSettings from './admin_settings.jsx'; import SettingsGroup from './settings_group.jsx'; import DropdownSetting from './dropdown_setting.jsx'; +import RadioSetting from './radio_setting.jsx'; +import PostEditSetting from './post_edit_setting.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -22,6 +24,9 @@ export default class PolicySettings extends AdminSettings { } getConfigFromState(config) { + config.ServiceSettings.RestrictPostDelete = this.state.restrictPostDelete; + config.ServiceSettings.AllowEditPost = this.state.allowEditPost; + config.ServiceSettings.PostEditTimeLimit = this.parseIntNonZero(this.state.postEditTimeLimit, Constants.DEFAULT_POST_EDIT_TIME_LIMIT); config.TeamSettings.RestrictTeamInvite = this.state.restrictTeamInvite; config.TeamSettings.RestrictPublicChannelCreation = this.state.restrictPublicChannelCreation; config.TeamSettings.RestrictPrivateChannelCreation = this.state.restrictPrivateChannelCreation; @@ -35,6 +40,9 @@ export default class PolicySettings extends AdminSettings { getStateFromConfig(config) { return { + restrictPostDelete: config.ServiceSettings.RestrictPostDelete, + allowEditPost: config.ServiceSettings.AllowEditPost, + postEditTimeLimit: config.ServiceSettings.PostEditTimeLimit, restrictTeamInvite: config.TeamSettings.RestrictTeamInvite, restrictPublicChannelCreation: config.TeamSettings.RestrictPublicChannelCreation, restrictPrivateChannelCreation: config.TeamSettings.RestrictPrivateChannelCreation, @@ -241,6 +249,47 @@ export default class PolicySettings extends AdminSettings { /> } /> + <RadioSetting + id='restrictPostDelete' + values={[ + {value: Constants.PERMISSIONS_DELETE_POST_ALL, text: Utils.localizeMessage('admin.general.policy.permissionsDeletePostAll', 'Message authors can delete their own messages, and Administrators can delete any message')}, + {value: Constants.PERMISSIONS_DELETE_POST_TEAM_ADMIN, text: Utils.localizeMessage('admin.general.policy.permissionsDeletePostAdmin', 'Team Admins and System Admins')}, + {value: Constants.PERMISSIONS_DELETE_POST_SYSTEM_ADMIN, text: Utils.localizeMessage('admin.general.policy.permissionsDeletePostSystemAdmin', 'System Admins')} + ]} + label={ + <FormattedMessage + id='admin.general.policy.restrictPostDeleteTitle' + defaultMessage='Allow which users to delete messages:' + /> + } + value={this.state.restrictPostDelete} + onChange={this.handleChange} + helpText={ + <FormattedHTMLMessage + id='admin.general.policy.restrictPostDeleteDescription' + defaultMessage='Set policy on who has permission to delete messages.' + /> + } + /> + <PostEditSetting + id='allowEditPost' + timeLimitId='postEditTimeLimit' + label={ + <FormattedMessage + id='admin.general.policy.allowEditPostTitle' + defaultMessage='Allow users to edit their messages:' + /> + } + value={this.state.allowEditPost} + timeLimitValue={this.state.postEditTimeLimit} + onChange={this.handleChange} + helpText={ + <FormattedHTMLMessage + id='admin.general.policy.allowEditPostDescription' + defaultMessage='Set policy on the length of time authors have to edit their messages after posting.' + /> + } + /> </SettingsGroup> ); } diff --git a/webapp/components/admin_console/post_edit_setting.jsx b/webapp/components/admin_console/post_edit_setting.jsx new file mode 100644 index 000000000..282a1b6c5 --- /dev/null +++ b/webapp/components/admin_console/post_edit_setting.jsx @@ -0,0 +1,99 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import Setting from './setting.jsx'; + +import Constants from 'utils/constants.jsx'; +import * as Utils from 'utils/utils.jsx'; + +export default class PostEditSetting extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleTimeLimitChange = this.handleTimeLimitChange.bind(this); + } + + handleChange(e) { + this.props.onChange(this.props.id, e.target.value); + } + + handleTimeLimitChange(e) { + this.props.onChange(this.props.timeLimitId, e.target.value); + } + + render() { + return ( + <Setting + label={this.props.label} + inputId={this.props.id} + helpText={this.props.helpText} + > + <div className='radio'> + <label> + <input + type='radio' + value={Constants.ALLOW_EDIT_POST_ALWAYS} + name={this.props.id} + checked={this.props.value === Constants.ALLOW_EDIT_POST_ALWAYS} + onChange={this.handleChange} + disabled={this.props.disabled} + /> + {Utils.localizeMessage('admin.general.policy.allowEditPostAlways', 'Any time')} + </label> + </div> + <div className='radio'> + <label> + <input + type='radio' + value={Constants.ALLOW_EDIT_POST_NEVER} + name={this.props.id} + checked={this.props.value === Constants.ALLOW_EDIT_POST_NEVER} + onChange={this.handleChange} + disabled={this.props.disabled} + /> + {Utils.localizeMessage('admin.general.policy.allowEditPostNever', 'Never')} + </label> + </div> + <div className='radio form-inline'> + <label> + <input + type='radio' + value={Constants.ALLOW_EDIT_POST_TIME_LIMIT} + name={this.props.id} + checked={this.props.value === Constants.ALLOW_EDIT_POST_TIME_LIMIT} + onChange={this.handleChange} + disabled={this.props.disabled} + /> + <input + type='text' + value={this.props.timeLimitValue} + className='form-control' + name={this.props.timeLimitId} + onChange={this.handleTimeLimitChange} + disabled={this.props.disabled || this.props.value !== Constants.ALLOW_EDIT_POST_TIME_LIMIT} + /> + <span> {Utils.localizeMessage('admin.general.policy.allowEditPostTimeLimit', 'seconds after posting')}</span> + </label> + </div> + </Setting> + ); + } +} + +PostEditSetting.defaultProps = { + isDisabled: false +}; + +PostEditSetting.propTypes = { + id: React.PropTypes.string.isRequired, + timeLimitId: React.PropTypes.string.isRequired, + label: React.PropTypes.node.isRequired, + value: React.PropTypes.string.isRequired, + timeLimitValue: React.PropTypes.number.isRequired, + onChange: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool, + helpText: React.PropTypes.node +}; diff --git a/webapp/components/admin_console/purge_caches.jsx b/webapp/components/admin_console/purge_caches.jsx index a999f090e..9f52433d5 100644 --- a/webapp/components/admin_console/purge_caches.jsx +++ b/webapp/components/admin_console/purge_caches.jsx @@ -3,10 +3,10 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; - import {FormattedMessage} from 'react-intl'; +import {invalidateAllCaches} from 'actions/admin_actions.jsx'; + export default class PurgeCachesButton extends React.Component { constructor(props) { super(props); @@ -27,7 +27,7 @@ export default class PurgeCachesButton extends React.Component { fail: null }); - Client.invalidateAllCaches( + invalidateAllCaches( () => { this.setState({ loading: false @@ -43,10 +43,6 @@ export default class PurgeCachesButton extends React.Component { } render() { - if (global.window.mm_license.IsLicensed !== 'true') { - return <div/>; - } - let testMessage = null; if (this.state.fail) { testMessage = ( diff --git a/webapp/components/admin_console/radio_setting.jsx b/webapp/components/admin_console/radio_setting.jsx new file mode 100644 index 000000000..dd45a5a26 --- /dev/null +++ b/webapp/components/admin_console/radio_setting.jsx @@ -0,0 +1,63 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import Setting from './setting.jsx'; + +export default class RadioSetting extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + } + + handleChange(e) { + this.props.onChange(this.props.id, e.target.value); + } + + render() { + const options = []; + for (const {value, text} of this.props.values) { + options.push( + <div className='radio'> + <label> + <input + type='radio' + value={value} + name={this.props.id} + checked={value === this.props.value} + onChange={this.handleChange} + disabled={this.props.disabled} + /> + {text} + </label> + </div> + ); + } + + return ( + <Setting + label={this.props.label} + inputId={this.props.id} + helpText={this.props.helpText} + > + {options} + </Setting> + ); + } +} + +RadioSetting.defaultProps = { + isDisabled: false +}; + +RadioSetting.propTypes = { + id: React.PropTypes.string.isRequired, + values: React.PropTypes.array.isRequired, + label: React.PropTypes.node.isRequired, + value: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool, + helpText: React.PropTypes.node +}; diff --git a/webapp/components/admin_console/recycle_db.jsx b/webapp/components/admin_console/recycle_db.jsx index 53e8e7436..5683f97e2 100644 --- a/webapp/components/admin_console/recycle_db.jsx +++ b/webapp/components/admin_console/recycle_db.jsx @@ -3,11 +3,12 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {recycleDatabaseConnection} from 'actions/admin_actions.jsx'; + export default class RecycleDbButton extends React.Component { constructor(props) { super(props); @@ -28,7 +29,7 @@ export default class RecycleDbButton extends React.Component { fail: null }); - Client.recycleDatabaseConnection( + recycleDatabaseConnection( () => { this.setState({ loading: false diff --git a/webapp/components/admin_console/reload_config.jsx b/webapp/components/admin_console/reload_config.jsx index 0b50d5803..25e9463d3 100644 --- a/webapp/components/admin_console/reload_config.jsx +++ b/webapp/components/admin_console/reload_config.jsx @@ -3,13 +3,12 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import {getConfig} from 'utils/async_client.jsx'; - import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {reloadConfig} from 'actions/admin_actions.jsx'; + export default class ReloadConfigButton extends React.Component { constructor(props) { super(props); @@ -30,9 +29,8 @@ export default class ReloadConfigButton extends React.Component { fail: null }); - Client.reloadConfig( + reloadConfig( () => { - getConfig(); this.setState({ loading: false }); diff --git a/webapp/components/admin_console/reset_password_modal.jsx b/webapp/components/admin_console/reset_password_modal.jsx index e3fd2bf00..757f85517 100644 --- a/webapp/components/admin_console/reset_password_modal.jsx +++ b/webapp/components/admin_console/reset_password_modal.jsx @@ -1,12 +1,13 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {Modal} from 'react-bootstrap'; import {injectIntl, intlShape, FormattedMessage} from 'react-intl'; +import {adminResetPassword} from 'actions/admin_actions.jsx'; + import React from 'react'; class ResetPasswordModal extends React.Component { @@ -32,7 +33,7 @@ class ResetPasswordModal extends React.Component { } this.setState({serverError: null}); - Client.adminResetPassword( + adminResetPassword( this.props.user.id, password, () => { diff --git a/webapp/components/admin_console/saml_settings.jsx b/webapp/components/admin_console/saml_settings.jsx index ad7a82553..7b9ed38b8 100644 --- a/webapp/components/admin_console/saml_settings.jsx +++ b/webapp/components/admin_console/saml_settings.jsx @@ -12,9 +12,10 @@ import RemoveFileSetting from './remove_file_setting.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; import SettingsGroup from './settings_group.jsx'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; +import {samlCertificateStatus, uploadCertificateFile, removeCertificateFile} from 'actions/admin_actions.jsx'; + export default class SamlSettings extends AdminSettings { constructor(props) { super(props); @@ -73,7 +74,7 @@ export default class SamlSettings extends AdminSettings { } componentWillMount() { - Client.samlCertificateStatus( + samlCertificateStatus( (data) => { const files = {}; if (!data.IdpCertificateFile) { @@ -93,7 +94,7 @@ export default class SamlSettings extends AdminSettings { } uploadCertificate(id, file, callback) { - Client.uploadCertificateFile( + uploadCertificateFile( file, () => { const fileName = file.name; @@ -112,7 +113,7 @@ export default class SamlSettings extends AdminSettings { } removeCertificate(id, callback) { - Client.removeCertificateFile( + removeCertificateFile( this.state[id], () => { this.handleChange(id, ''); diff --git a/webapp/components/admin_console/sync_now_button.jsx b/webapp/components/admin_console/sync_now_button.jsx index 95d126291..f1197b216 100644 --- a/webapp/components/admin_console/sync_now_button.jsx +++ b/webapp/components/admin_console/sync_now_button.jsx @@ -3,11 +3,12 @@ import React from 'react'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {ldapSyncNow} from 'actions/admin_actions.jsx'; + export default class SyncNowButton extends React.Component { static get propTypes() { return { @@ -33,7 +34,7 @@ export default class SyncNowButton extends React.Component { fail: null }); - Client.ldapSyncNow( + ldapSyncNow( () => { this.setState({ buisy: false diff --git a/webapp/components/admin_console/team_users.jsx b/webapp/components/admin_console/team_users.jsx index 4517e241b..547002a5b 100644 --- a/webapp/components/admin_console/team_users.jsx +++ b/webapp/components/admin_console/team_users.jsx @@ -158,13 +158,17 @@ export default class UserList extends React.Component { clearTimeout(this.searchTimeoutId); - this.searchTimeoutId = setTimeout( + const searchTimeoutId = setTimeout( () => { searchUsers( term, this.props.params.team, options, (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } + this.setState({loading: true, search: true, users}); loadTeamMembersForProfilesList(users, this.props.params.team, this.loadComplete); } @@ -172,6 +176,8 @@ export default class UserList extends React.Component { }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); + + this.searchTimeoutId = searchTimeoutId; } render() { diff --git a/webapp/components/analytics/system_analytics.jsx b/webapp/components/analytics/system_analytics.jsx index dd7b90260..89cc98f0b 100644 --- a/webapp/components/analytics/system_analytics.jsx +++ b/webapp/components/analytics/system_analytics.jsx @@ -358,6 +358,32 @@ class SystemAnalytics extends React.Component { /> ); + const dailyActiveUsers = ( + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.dailyActiveUsers' + defaultMessage='Daily Active Users' + /> + } + icon='fa-users' + count={stats[StatTypes.DAILY_ACTIVE_USERS]} + /> + ); + + const monthlyActiveUsers = ( + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.monthlyActiveUsers' + defaultMessage='Monthly Active Users' + /> + } + icon='fa-users' + count={stats[StatTypes.MONTHLY_ACTIVE_USERS]} + /> + ); + let firstRow; let secondRow; if (isLicensed && skippedIntensiveQueries) { @@ -406,6 +432,13 @@ class SystemAnalytics extends React.Component { ); } + const thirdRow = ( + <div className='row'> + {dailyActiveUsers} + {monthlyActiveUsers} + </div> + ); + return ( <div className='wrapper--fixed team_statistics'> <h3> @@ -417,6 +450,7 @@ class SystemAnalytics extends React.Component { {banner} {firstRow} {secondRow} + {thirdRow} {advancedStats} {advancedGraphs} {postTotalGraph} diff --git a/webapp/components/authorize.jsx b/webapp/components/authorize.jsx index 684bae589..f3f5770de 100644 --- a/webapp/components/authorize.jsx +++ b/webapp/components/authorize.jsx @@ -1,7 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import Client from 'client/web_client.jsx'; import FormError from 'components/form_error.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -9,6 +8,8 @@ import React from 'react'; import icon50 from 'images/icon50x50.png'; +import {getOAuthAppInfo, allowOAuth2} from 'actions/admin_actions.jsx'; + export default class Authorize extends React.Component { static get propTypes() { return { @@ -27,7 +28,7 @@ export default class Authorize extends React.Component { } componentWillMount() { - Client.getOAuthAppInfo( + getOAuthAppInfo( this.props.location.query.client_id, (app) => { this.setState({app}); @@ -46,7 +47,7 @@ export default class Authorize extends React.Component { handleAllow() { const params = this.props.location.query; - Client.allowOAuth2(params.response_type, params.client_id, params.redirect_uri, params.state, params.scope, + allowOAuth2(params, (data) => { if (data.redirect) { window.location.href = data.redirect; diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index fc0ec132e..e83d493f4 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -29,15 +29,12 @@ import * as ChannelActions from 'actions/channel_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; -import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import {getFlaggedPosts} from 'actions/post_actions.jsx'; import {Constants, Preferences, UserStatuses} from 'utils/constants.jsx'; import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {browserHistory} from 'react-router/es6'; import {Tooltip, OverlayTrigger, Popover} from 'react-bootstrap'; const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; @@ -131,21 +128,7 @@ export default class ChannelHeader extends React.Component { } handleLeave() { - Client.leaveChannel(this.state.channel.id, - () => { - const channelId = this.state.channel.id; - - if (this.state.isFavorite) { - ChannelActions.unmarkFavorite(channelId); - } - - const townsquare = ChannelStore.getByName('town-square'); - browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + townsquare.name); - }, - (err) => { - AsyncClient.dispatchError(err, 'handleLeave'); - } - ); + ChannelActions.leaveChannel(this.state.channel.id); } toggleFavorite = (e) => { @@ -438,7 +421,7 @@ export default class ChannelHeader extends React.Component { dialogProps={{channel, currentUser: this.state.currentUser}} > <FormattedMessage - id='chanel_header.addMembers' + id='channel_header.addMembers' defaultMessage='Add Members' /> </ToggleModalButton> diff --git a/webapp/components/channel_invite_modal.jsx b/webapp/components/channel_invite_modal.jsx index 355d23d53..5deec0794 100644 --- a/webapp/components/channel_invite_modal.jsx +++ b/webapp/components/channel_invite_modal.jsx @@ -117,19 +117,25 @@ export default class ChannelInviteModal extends React.Component { clearTimeout(this.searchTimeoutId); - this.searchTimeoutId = setTimeout( + const searchTimeoutId = setTimeout( () => { searchUsers( term, TeamStore.getCurrentId(), {not_in_channel_id: this.props.channel.id}, (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } + this.setState({search: true, users}); } ); }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); + + this.searchTimeoutId = searchTimeoutId; } render() { diff --git a/webapp/components/channel_members_dropdown.jsx b/webapp/components/channel_members_dropdown.jsx new file mode 100644 index 000000000..a7b3259af --- /dev/null +++ b/webapp/components/channel_members_dropdown.jsx @@ -0,0 +1,246 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelStore from 'stores/channel_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {removeUserFromChannel, makeUserChannelAdmin, makeUserChannelMember} from 'actions/channel_actions.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +export default class ChannelMembersDropdown extends React.Component { + constructor(props) { + super(props); + + this.handleRemoveFromChannel = this.handleRemoveFromChannel.bind(this); + this.handleMakeChannelMember = this.handleMakeChannelMember.bind(this); + this.handleMakeChannelAdmin = this.handleMakeChannelAdmin.bind(this); + + this.state = { + serverError: null, + user: null, + role: null + }; + } + + handleRemoveFromChannel() { + removeUserFromChannel( + this.props.channel.id, + this.props.user.id, + () => { + AsyncClient.getChannelStats(this.props.channel.id); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeChannelMember() { + makeUserChannelMember( + this.props.channel.id, + this.props.user.id, + () => { + AsyncClient.getChannelStats(this.props.channel.id); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeChannelAdmin() { + makeUserChannelAdmin( + this.props.channel.id, + this.props.user.id, + () => { + AsyncClient.getChannelStats(this.props.channel.id); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + // Checks if the user this menu is for is a channel admin or not. + isChannelAdmin() { + if (Utils.isChannelAdmin(this.props.channelMember.roles)) { + return true; + } + + return false; + } + + // Checks if the current user has the power to change the roles of this member. + canChangeMemberRoles() { + if (UserStore.isSystemAdminForCurrentUser()) { + return true; + } else if (TeamStore.isTeamAdminForCurrentTeam()) { + return true; + } else if (ChannelStore.isChannelAdminForCurrentChannel()) { + return true; + } + + return false; + } + + // Checks if the current user has the power to remove this member from the channel. + canRemoveMember() { + // TODO: This will be implemented as part of PLT-5047. + return true; + } + + render() { + let serverError = null; + if (this.state.serverError) { + serverError = ( + <div className='has-error'> + <label className='has-error control-label'>{this.state.serverError}</label> + </div> + ); + } + + if (this.props.user.id === UserStore.getCurrentId()) { + return null; + } + + if (this.canChangeMemberRoles()) { + let role = ( + <FormattedMessage + id='channel_members_dropdown.channel_member' + defaultMessage='Channel Member' + /> + ); + + if (this.isChannelAdmin()) { + role = ( + <FormattedMessage + id='channel_members_dropdown.channel_admin' + defaultMessage='Channel Admin' + /> + ); + } + + let removeFromChannel = null; + if (this.canRemoveMember()) { + removeFromChannel = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.handleRemoveFromChannel} + > + <FormattedMessage + id='channel_members_dropdown.remove_from_channel' + defaultMessage='Remove From Channel' + /> + </a> + </li> + ); + } + + let makeChannelMember = null; + if (this.isChannelAdmin()) { + makeChannelMember = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.handleMakeChannelMember} + > + <FormattedMessage + id='channel_members_dropdown.make_channel_member' + defaultMessage='Make Channel Member' + /> + </a> + </li> + ); + } + + let makeChannelAdmin = null; + if (!this.isChannelAdmin()) { + makeChannelAdmin = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.handleMakeChannelAdmin} + > + <FormattedMessage + id='channel_members_dropdown.make_channel_admin' + defaultMessage='Make Channel Admin' + /> + </a> + </li> + ); + } + + return ( + <div className='dropdown member-drop'> + <a + href='#' + className='dropdown-toggle theme' + type='button' + data-toggle='dropdown' + aria-expanded='true' + > + <span>{role} </span> + <span className='fa fa-chevron-down'/> + </a> + <ul + className='dropdown-menu member-menu' + role='menu' + > + {makeChannelMember} + {makeChannelAdmin} + {removeFromChannel} + </ul> + {serverError} + </div> + ); + } else if (this.canRemoveMember()) { + return ( + <button + type='button' + className='btn btn-danger btn-message' + onClick={this.handleRemoveFromChannel} + > + <FormattedMessage + id='channel_members_dropdown.remove_member' + defaultMessage='Remove Member' + /> + </button> + ); + } else if (this.isChannelAdmin()) { + return ( + <div> + <FormattedMessage + id='channel_members_dropdown.channel_admin' + defaultMessage='Channel Admin' + /> + </div> + ); + } + + return ( + <div> + <FormattedMessage + id='channel_members_dropdown.channel_member' + defaultMessage='Channel Member' + /> + </div> + ); + } +} + +ChannelMembersDropdown.propTypes = { + channel: React.PropTypes.object.isRequired, + user: React.PropTypes.object.isRequired, + teamMember: React.PropTypes.object.isRequired, + channelMember: React.PropTypes.object.isRequired +}; diff --git a/webapp/components/channel_members_modal.jsx b/webapp/components/channel_members_modal.jsx index 351efed96..96d90e5cc 100644 --- a/webapp/components/channel_members_modal.jsx +++ b/webapp/components/channel_members_modal.jsx @@ -1,170 +1,29 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import SearchableUserList from './searchable_user_list.jsx'; -import LoadingScreen from './loading_screen.jsx'; - -import UserStore from 'stores/user_store.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; -import TeamStore from 'stores/team_store.jsx'; - -import {searchUsers} from 'actions/user_actions.jsx'; -import {removeUserFromChannel} from 'actions/channel_actions.jsx'; - -import * as AsyncClient from 'utils/async_client.jsx'; -import * as UserAgent from 'utils/user_agent.jsx'; -import Constants from 'utils/constants.jsx'; +import MemberListChannel from './member_list_channel.jsx'; import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; -const USERS_PER_PAGE = 50; - export default class ChannelMembersModal extends React.Component { constructor(props) { super(props); - this.onChange = this.onChange.bind(this); - this.onStatusChange = this.onStatusChange.bind(this); this.onHide = this.onHide.bind(this); - this.handleRemove = this.handleRemove.bind(this); - this.createRemoveMemberButton = this.createRemoveMemberButton.bind(this); - this.search = this.search.bind(this); - this.nextPage = this.nextPage.bind(this); - - this.term = ''; - this.searchTimeoutId = 0; - - const stats = ChannelStore.getStats(props.channel.id); this.state = { - users: [], - total: stats.member_count, - show: true, - search: false, - statusChange: false + channel: this.props.channel, + show: true }; } - componentDidMount() { - ChannelStore.addStatsChangeListener(this.onChange); - UserStore.addInChannelChangeListener(this.onChange); - UserStore.addStatusesChangeListener(this.onStatusChange); - - AsyncClient.getProfilesInChannel(this.props.channel.id, 0); - } - - componentWillUnmount() { - ChannelStore.removeStatsChangeListener(this.onChange); - UserStore.removeInChannelChangeListener(this.onChange); - UserStore.removeStatusesChangeListener(this.onStatusChange); - } - - onChange(force) { - if (this.state.search && !force) { - this.search(this.term); - return; - } - - const stats = ChannelStore.getStats(this.props.channel.id); - this.setState({ - users: UserStore.getProfileListInChannel(this.props.channel.id), - total: stats.member_count - }); - } - - onStatusChange() { - // Initiate a render to pick up on new statuses - this.setState({ - statusChange: !this.state.statusChange - }); - } - onHide() { this.setState({show: false}); } - handleRemove(user) { - const userId = user.id; - - removeUserFromChannel( - this.props.channel.id, - userId, - null, - (err) => { - this.setState({inviteError: err.message}); - } - ); - } - - createRemoveMemberButton({user}) { - if (user.id === UserStore.getCurrentId()) { - return null; - } - - return ( - <button - type='button' - className='btn btn-primary btn-message' - onClick={this.handleRemove.bind(this, user)} - > - <FormattedMessage - id='channel_members_modal.remove' - defaultMessage='Remove' - /> - </button> - ); - } - - nextPage(page) { - AsyncClient.getProfilesInChannel(this.props.channel.id, (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); - } - - search(term) { - this.term = term; - - if (term === '') { - this.onChange(true); - this.setState({search: false}); - return; - } - - clearTimeout(this.searchTimeoutId); - - this.searchTimeoutId = setTimeout( - () => { - searchUsers( - term, - TeamStore.getCurrentId(), - {in_channel_id: this.props.channel.id}, - (users) => { - this.setState({search: true, users}); - } - ); - }, - Constants.SEARCH_TIMEOUT_MILLISECONDS - ); - } - render() { - let content; - if (this.state.loading) { - content = (<LoadingScreen/>); - } else { - content = ( - <SearchableUserList - users={this.state.users} - usersPerPage={USERS_PER_PAGE} - total={this.state.total} - nextPage={this.nextPage} - search={this.search} - actions={[this.createRemoveMemberButton]} - focusOnMount={!UserAgent.isMobile()} - /> - ); - } - return ( <div> <Modal @@ -177,7 +36,7 @@ export default class ChannelMembersModal extends React.Component { <Modal.Title> <span className='name'>{this.props.channel.display_name}</span> <FormattedMessage - id='channel_memebers_modal.members' + id='channel_members_modal.members' defaultMessage=' Members' /> </Modal.Title> @@ -198,7 +57,9 @@ export default class ChannelMembersModal extends React.Component { <Modal.Body ref='modalBody' > - {content} + <MemberListChannel + channel={this.props.channel} + /> </Modal.Body> </Modal> </div> diff --git a/webapp/components/channel_select.jsx b/webapp/components/channel_select.jsx index 59bf2f15a..194de3874 100644 --- a/webapp/components/channel_select.jsx +++ b/webapp/components/channel_select.jsx @@ -31,12 +31,13 @@ export default class ChannelSelect extends React.Component { super(props); this.handleChannelChange = this.handleChannelChange.bind(this); + this.filterChannels = this.filterChannels.bind(this); this.compareByDisplayName = this.compareByDisplayName.bind(this); AsyncClient.getMoreChannels(true); this.state = { - channels: ChannelStore.getAll().sort(this.compareByDisplayName) + channels: ChannelStore.getAll().filter(this.filterChannels).sort(this.compareByDisplayName) }; } @@ -50,10 +51,19 @@ export default class ChannelSelect extends React.Component { handleChannelChange() { this.setState({ - channels: ChannelStore.getAll().concat(ChannelStore.getMoreAll()).sort(this.compareByDisplayName) + channels: ChannelStore.getAll().concat(ChannelStore.getMoreAll()). + filter(this.filterChannels).sort(this.compareByDisplayName) }); } + filterChannels(channel) { + if (channel.display_name) { + return true; + } + + return false; + } + compareByDisplayName(channelA, channelB) { return channelA.display_name.localeCompare(channelB.display_name); } diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx index 2f8595c78..fc66e06b1 100644 --- a/webapp/components/channel_switch_modal.jsx +++ b/webapp/components/channel_switch_modal.jsx @@ -64,6 +64,7 @@ export default class SwitchChannelModal extends React.Component { } onExited() { + this.selected = null; setTimeout(() => { $('#post_textbox').get(0).focus(); }); @@ -71,6 +72,7 @@ export default class SwitchChannelModal extends React.Component { onChange(e) { this.setState({text: e.target.value}); + this.selected = null; } onItemSelected(item) { @@ -89,6 +91,15 @@ export default class SwitchChannelModal extends React.Component { handleSubmit() { let channel = null; + if (!this.selected) { + if (this.state.text !== '') { + this.setState({ + error: Utils.localizeMessage('channel_switch_modal.not_found', 'No matches found.') + }); + } + return; + } + if (this.selected.type === Constants.DM_CHANNEL) { const user = UserStore.getProfileByUsername(this.selected.name); @@ -117,7 +128,7 @@ export default class SwitchChannelModal extends React.Component { this.onHide(); } else if (this.state.text !== '') { this.setState({ - error: Utils.localizeMessage('channel_switch_modal.not_found', 'No matches found.') + error: Utils.localizeMessage('channel_switch_modal.failed_to_open', 'Failed to open channel.') }); } } diff --git a/webapp/components/claim/components/email_to_ldap.jsx b/webapp/components/claim/components/email_to_ldap.jsx index 890512803..7d062a957 100644 --- a/webapp/components/claim/components/email_to_ldap.jsx +++ b/webapp/components/claim/components/email_to_ldap.jsx @@ -4,9 +4,9 @@ import LoginMfa from 'components/login/components/login_mfa.jsx'; import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; import {checkMfa} from 'actions/user_actions.jsx'; +import {emailToLdap} from 'actions/admin_actions.jsx'; import React from 'react'; import {FormattedMessage} from 'react-intl'; @@ -79,7 +79,7 @@ export default class EmailToLDAP extends React.Component { } submit(loginId, password, token, ldapId, ldapPassword) { - Client.emailToLdap( + emailToLdap( loginId, password, token, diff --git a/webapp/components/claim/components/email_to_oauth.jsx b/webapp/components/claim/components/email_to_oauth.jsx index 3cede15a3..bc5a7bdaa 100644 --- a/webapp/components/claim/components/email_to_oauth.jsx +++ b/webapp/components/claim/components/email_to_oauth.jsx @@ -4,10 +4,10 @@ import LoginMfa from 'components/login/components/login_mfa.jsx'; import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import {checkMfa} from 'actions/user_actions.jsx'; +import {emailToOAuth} from 'actions/admin_actions.jsx'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -55,7 +55,7 @@ export default class EmailToOAuth extends React.Component { } submit(loginId, password, token) { - Client.emailToOAuth( + emailToOAuth( loginId, password, token, diff --git a/webapp/components/claim/components/oauth_to_email.jsx b/webapp/components/claim/components/oauth_to_email.jsx index ed604583d..ffba1c331 100644 --- a/webapp/components/claim/components/oauth_to_email.jsx +++ b/webapp/components/claim/components/oauth_to_email.jsx @@ -2,13 +2,13 @@ // See License.txt for license information. import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import React from 'react'; import ReactDOM from 'react-dom'; import {FormattedMessage} from 'react-intl'; -import {browserHistory} from 'react-router/es6'; + +import {oauthToEmail} from 'actions/admin_actions.jsx'; export default class OAuthToEmail extends React.Component { constructor(props) { @@ -48,14 +48,10 @@ export default class OAuthToEmail extends React.Component { state.error = null; this.setState(state); - Client.oauthToEmail( + oauthToEmail( this.props.email, password, - (data) => { - if (data.follow_link) { - browserHistory.push(data.follow_link); - } - }, + null, (err) => { this.setState({error: err.message}); } diff --git a/webapp/components/create_comment.jsx b/webapp/components/create_comment.jsx index 0e9d2a41a..d9d66c8fa 100644 --- a/webapp/components/create_comment.jsx +++ b/webapp/components/create_comment.jsx @@ -331,6 +331,9 @@ export default class CreateComment extends React.Component { draft.fileInfos = draft.fileInfos.concat(fileInfos); PostStore.storeCommentDraft(this.props.rootId, draft); + // Focus on preview if needed + this.refs.preview.refs.container.scrollIntoViewIfNeeded(); + this.setState({uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos}); } @@ -355,6 +358,9 @@ export default class CreateComment extends React.Component { const fileInfos = this.state.fileInfos; const uploadsInProgress = this.state.uploadsInProgress; + // Clear previous errors + this.handleUploadError(null); + // id can either be the id of an uploaded file or the client id of an in progress upload let index = fileInfos.findIndex((info) => info.id === id); if (index === -1) { @@ -432,6 +438,7 @@ export default class CreateComment extends React.Component { fileInfos={this.state.fileInfos} onRemove={this.removePreview} uploadsInProgress={this.state.uploadsInProgress} + ref='preview' /> ); } diff --git a/webapp/components/create_post.jsx b/webapp/components/create_post.jsx index e1b2ca059..9269633ff 100644 --- a/webapp/components/create_post.jsx +++ b/webapp/components/create_post.jsx @@ -297,6 +297,9 @@ export default class CreatePost extends React.Component { const fileInfos = Object.assign([], this.state.fileInfos); const uploadsInProgress = this.state.uploadsInProgress; + // Clear previous errors + this.handleUploadError(null); + // id can either be the id of an uploaded file or the client id of an in progress upload let index = fileInfos.findIndex((info) => info.id === id); if (index === -1) { diff --git a/webapp/components/delete_channel_modal.jsx b/webapp/components/delete_channel_modal.jsx index 1b642861a..a6577a4a9 100644 --- a/webapp/components/delete_channel_modal.jsx +++ b/webapp/components/delete_channel_modal.jsx @@ -1,8 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import {Modal} from 'react-bootstrap'; import TeamStore from 'stores/team_store.jsx'; import Constants from 'utils/constants.jsx'; @@ -13,7 +11,7 @@ import {browserHistory} from 'react-router/es6'; import React from 'react'; -import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; +import {deleteChannel} from 'actions/channel_actions.jsx'; export default class DeleteChannelModal extends React.Component { constructor(props) { @@ -31,15 +29,7 @@ export default class DeleteChannelModal extends React.Component { } browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/town-square'); - Client.deleteChannel( - this.props.channel.id, - () => { - loadChannelsForCurrentUser(); - }, - (err) => { - AsyncClient.dispatchError(err, 'handleDelete'); - } - ); + deleteChannel(this.props.channel.id); } onHide() { diff --git a/webapp/components/delete_post_modal.jsx b/webapp/components/delete_post_modal.jsx index 84eef4671..39d4f41f9 100644 --- a/webapp/components/delete_post_modal.jsx +++ b/webapp/components/delete_post_modal.jsx @@ -3,13 +3,9 @@ import $ from 'jquery'; import ReactDOM from 'react-dom'; -import Client from 'client/web_client.jsx'; -import PostStore from 'stores/post_store.jsx'; -import ModalStore from 'stores/modal_store.jsx'; import {Modal} from 'react-bootstrap'; -import * as AsyncClient from 'utils/async_client.jsx'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import {removePostFromStore} from 'actions/post_actions.jsx'; +import ModalStore from 'stores/modal_store.jsx'; +import {deletePost} from 'actions/post_actions.jsx'; import Constants from 'utils/constants.jsx'; import {FormattedMessage} from 'react-intl'; @@ -51,24 +47,16 @@ export default class DeletePostModal extends React.Component { } handleDelete() { - Client.deletePost( + deletePost( this.state.post.channel_id, - this.state.post.id, + this.state.post, () => { - removePostFromStore(this.state.post); - if (this.state.post.id === PostStore.getSelectedPostId()) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_SELECTED, - postId: null - }); - } + this.handleHide(); }, (err) => { - AsyncClient.dispatchError(err, 'deletePost'); + this.setState({error: err.message}); } ); - - this.handleHide(); } handleToggle(value, args) { diff --git a/webapp/components/do_verify_email.jsx b/webapp/components/do_verify_email.jsx index e0ac3218e..9b6a9ccad 100644 --- a/webapp/components/do_verify_email.jsx +++ b/webapp/components/do_verify_email.jsx @@ -2,11 +2,12 @@ // See License.txt for license information. import {FormattedMessage} from 'react-intl'; -import Client from 'client/web_client.jsx'; import LoadingScreen from './loading_screen.jsx'; import {browserHistory, Link} from 'react-router/es6'; +import {verifyEmail} from 'actions/user_actions.jsx'; + import React from 'react'; export default class DoVerifyEmail extends React.Component { @@ -19,15 +20,11 @@ export default class DoVerifyEmail extends React.Component { }; } componentWillMount() { - const uid = this.props.location.query.uid; - const hid = this.props.location.query.hid; - const email = this.props.location.query.email; - - Client.verifyEmail( - uid, - hid, + verifyEmail( + this.props.location.query.uid, + this.props.location.query.hid, () => { - browserHistory.push('/login?extra=verified&email=' + email); + browserHistory.push('/login?extra=verified&email=' + this.props.location.query.email); }, (err) => { this.setState({verifyStatus: 'failure', serverError: err.message}); diff --git a/webapp/components/edit_channel_header_modal.jsx b/webapp/components/edit_channel_header_modal.jsx index 490b9fb31..0d8eb8acb 100644 --- a/webapp/components/edit_channel_header_modal.jsx +++ b/webapp/components/edit_channel_header_modal.jsx @@ -2,13 +2,12 @@ // See License.txt for license information. import ReactDOM from 'react-dom'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; +import {updateChannelHeader} from 'actions/channel_actions.jsx'; import {Modal} from 'react-bootstrap'; @@ -64,17 +63,12 @@ class EditChannelHeaderModal extends React.Component { handleSubmit() { this.setState({submitted: true}); - Client.updateChannelHeader( + updateChannelHeader( this.props.channel.id, this.state.header, - (channel) => { + () => { this.setState({serverError: ''}); this.onHide(); - - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.RECEIVED_CHANNEL, - channel - }); }, (err) => { if (err.id === 'api.context.invalid_param.app_error') { diff --git a/webapp/components/edit_channel_purpose_modal.jsx b/webapp/components/edit_channel_purpose_modal.jsx index 7ba2eff2c..4bb876460 100644 --- a/webapp/components/edit_channel_purpose_modal.jsx +++ b/webapp/components/edit_channel_purpose_modal.jsx @@ -3,14 +3,13 @@ import PreferenceStore from 'stores/preference_store.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import {updateChannelPurpose} from 'actions/channel_actions.jsx'; export default class EditChannelPurposeModal extends React.Component { constructor(props) { @@ -64,12 +63,10 @@ export default class EditChannelPurposeModal extends React.Component { this.setState({submitted: true}); - Client.updateChannelPurpose( + updateChannelPurpose( this.props.channel.id, this.refs.purpose.value.trim(), () => { - AsyncClient.getChannel(this.props.channel.id); - this.handleHide(); }, (err) => { diff --git a/webapp/components/edit_post_modal.jsx b/webapp/components/edit_post_modal.jsx index 2108ec3d1..b2b607428 100644 --- a/webapp/components/edit_post_modal.jsx +++ b/webapp/components/edit_post_modal.jsx @@ -9,11 +9,9 @@ import MessageHistoryStore from 'stores/message_history_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import {loadPosts} from 'actions/post_actions.jsx'; +import {updatePost} from 'actions/post_actions.jsx'; -import Client from 'client/web_client.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; const KeyCodes = Constants.KeyCodes; @@ -91,15 +89,12 @@ export default class EditPostModal extends React.Component { return; } - Client.updatePost( + updatePost( updatedPost, () => { - loadPosts(updatedPost.channel_id); window.scrollTo(0, 0); }, - (err) => { - AsyncClient.dispatchError(err, 'updatePost'); - } + Boolean(PostStore.getFocusedPostId()) // If there is focused post we need to update that post's store too. ); $('#edit_post').modal('hide'); @@ -125,6 +120,17 @@ export default class EditPostModal extends React.Component { } handleEditPostEvent(options) { + var post = PostStore.getPost(options.channelId, options.postId); + if (global.window.mm_license.IsLicensed === 'true') { + if (global.window.mm_config.AllowEditPost === Constants.ALLOW_EDIT_POST_NEVER) { + return; + } + if (global.window.mm_config.AllowEditPost === Constants.ALLOW_EDIT_POST_TIME_LIMIT) { + if ((post.create_at + (global.window.mm_config.PostEditTimeLimit * 1000)) < Utils.getTimestamp()) { + return; + } + } + } this.setState({ editText: options.message || '', originalText: options.message || '', @@ -180,7 +186,10 @@ export default class EditPostModal extends React.Component { onModalHide() { if (this.state.refocusId !== '') { setTimeout(() => { - $(this.state.refocusId).get(0).focus(); + const element = $(this.state.refocusId).get(0); + if (element) { + element.focus(); + } }); } } diff --git a/webapp/components/file_attachment_list.jsx b/webapp/components/file_attachment_list.jsx index 3d39d8709..472cd2686 100644 --- a/webapp/components/file_attachment_list.jsx +++ b/webapp/components/file_attachment_list.jsx @@ -50,7 +50,7 @@ export default class FileAttachmentList extends React.Component { return ( <div> - <div className='post-image__columns'> + <div className='post-image__columns clearfix'> {postFiles} </div> <ViewImageModal diff --git a/webapp/components/file_preview.jsx b/webapp/components/file_preview.jsx index 53cec7f7b..624bfaf44 100644 --- a/webapp/components/file_preview.jsx +++ b/webapp/components/file_preview.jsx @@ -84,7 +84,10 @@ export default class FilePreview extends React.Component { }); return ( - <div className='file-preview__container'> + <div + className='file-preview__container' + ref='container' + > {previews} </div> ); diff --git a/webapp/components/file_upload.jsx b/webapp/components/file_upload.jsx index 9eff25ab5..a821fedab 100644 --- a/webapp/components/file_upload.jsx +++ b/webapp/components/file_upload.jsx @@ -4,7 +4,6 @@ import $ from 'jquery'; import 'jquery-dragster/jquery.dragster.js'; import ReactDOM from 'react-dom'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import DelayedAction from 'utils/delayed_action.jsx'; @@ -13,6 +12,8 @@ import * as Utils from 'utils/utils.jsx'; import {intlShape, injectIntl, defineMessages} from 'react-intl'; +import {uploadFile} from 'actions/file_actions.jsx'; + const holders = defineMessages({ limited: { id: 'file_upload.limited', @@ -47,6 +48,7 @@ class FileUpload extends React.Component { this.cancelUpload = this.cancelUpload.bind(this); this.pasteUpload = this.pasteUpload.bind(this); this.keyUpload = this.keyUpload.bind(this); + this.handleMaxUploadReached = this.handleMaxUploadReached.bind(this); this.state = { requests: {} @@ -88,13 +90,14 @@ class FileUpload extends React.Component { // generate a unique id that can be used by other components to refer back to this upload const clientId = Utils.generateId(); - const request = Client.uploadFile(files[i], - files[i].name, - channelId, - clientId, - this.fileUploadSuccess.bind(this, channelId), - this.fileUploadFail.bind(this, clientId, channelId) - ); + const request = uploadFile( + files[i], + files[i].name, + channelId, + clientId, + this.fileUploadSuccess.bind(this, channelId), + this.fileUploadFail.bind(this, clientId) + ); const requests = this.state.requests; requests[clientId] = request; @@ -270,7 +273,8 @@ class FileUpload extends React.Component { const name = formatMessage(holders.pasted) + d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hour + '-' + min + '.' + ext; - const request = Client.uploadFile(file, + const request = uploadFile( + file, name, channelId, clientId, @@ -309,6 +313,16 @@ class FileUpload extends React.Component { } } + handleMaxUploadReached(e) { + e.preventDefault(); + + const {formatMessage} = this.props.intl; + + this.props.onUploadError(formatMessage(holders.limited, {count: Constants.MAX_UPLOAD_FILES})); + + return false; + } + render() { let multiple = true; if (UserAgent.isMobileApp()) { @@ -322,10 +336,14 @@ class FileUpload extends React.Component { accept = 'image/*'; } + const channelId = this.props.channelId || ChannelStore.getCurrentId(); + + const uploadsRemaining = Constants.MAX_UPLOAD_FILES - this.props.getFileCount(channelId); + return ( <span ref='input' - className='btn btn-file' + className={'btn btn-file' + (uploadsRemaining <= 0 ? ' btn-file__disabled' : '')} > <span className='icon' @@ -335,7 +353,7 @@ class FileUpload extends React.Component { ref='fileInput' type='file' onChange={this.handleChange} - onClick={this.props.onClick} + onClick={uploadsRemaining > 0 ? this.props.onClick : this.handleMaxUploadReached} multiple={multiple} accept={accept} /> diff --git a/webapp/components/integrations/components/installed_oauth_app.jsx b/webapp/components/integrations/components/installed_oauth_app.jsx index 15a79ed4c..a6dea65bf 100644 --- a/webapp/components/integrations/components/installed_oauth_app.jsx +++ b/webapp/components/integrations/components/installed_oauth_app.jsx @@ -5,10 +5,10 @@ import React from 'react'; import FormError from 'components/form_error.jsx'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {regenerateOAuthAppSecret} from 'actions/admin_actions.jsx'; const FAKE_SECRET = '***************'; @@ -49,7 +49,7 @@ export default class InstalledOAuthApp extends React.Component { handleRegenerate(e) { e.preventDefault(); - Client.regenerateOAuthAppSecret( + regenerateOAuthAppSecret( this.props.oauthApp.id, (data) => { this.props.oauthApp.client_secret = data.client_secret; diff --git a/webapp/components/invite_member_modal.jsx b/webapp/components/invite_member_modal.jsx index f4fd1d712..563c1aba9 100644 --- a/webapp/components/invite_member_modal.jsx +++ b/webapp/components/invite_member_modal.jsx @@ -5,13 +5,13 @@ import ReactDOM from 'react-dom'; import * as utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; -import Client from 'client/web_client.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import ModalStore from 'stores/modal_store.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import ConfirmModal from './confirm_modal.jsx'; +import {inviteMembers} from 'actions/team_actions.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -142,7 +142,7 @@ class InviteMemberModal extends React.Component { this.setState({isSendingEmails: true}); - Client.inviteMembers( + inviteMembers( data, () => { this.handleHide(false); diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx index 841061d48..9282e74ca 100644 --- a/webapp/components/logged_in.jsx +++ b/webapp/components/logged_in.jsx @@ -4,7 +4,6 @@ import LoadingScreen from 'components/loading_screen.jsx'; import UserStore from 'stores/user_store.jsx'; -import BrowserStore from 'stores/browser_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; @@ -29,30 +28,6 @@ export default class LoggedIn extends React.Component { this.onUserChanged = this.onUserChanged.bind(this); this.setupUser = this.setupUser.bind(this); - // Force logout of all tabs if one tab is logged out - $(window).bind('storage', (e) => { - // when one tab on a browser logs out, it sets __logout__ in localStorage to trigger other tabs to log out - if (e.originalEvent.key === '__logout__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { - // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) - if (BrowserStore.isSignallingLogout(e.originalEvent.newValue)) { - return; - } - - console.log('detected logout from a different tab'); //eslint-disable-line no-console - GlobalActions.emitUserLoggedOutEvent('/', false); - } - - if (e.originalEvent.key === '__login__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { - // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) - if (BrowserStore.isSignallingLogin(e.originalEvent.newValue)) { - return; - } - - console.log('detected login from a different tab'); //eslint-disable-line no-console - location.reload(); - } - }); - // Because current CSS requires the root tag to have specific stuff $('#root').attr('class', 'channel-view'); diff --git a/webapp/components/login/login_controller.jsx b/webapp/components/login/login_controller.jsx index 6dc7af883..535cdfd12 100644 --- a/webapp/components/login/login_controller.jsx +++ b/webapp/components/login/login_controller.jsx @@ -6,6 +6,8 @@ import ErrorBar from 'components/error_bar.jsx'; import FormError from 'components/form_error.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import {addUserToTeamFromInvite} from 'actions/team_actions.jsx'; +import {checkMfa, webLogin} from 'actions/user_actions.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import UserStore from 'stores/user_store.jsx'; @@ -47,7 +49,8 @@ export default class LoginController extends React.Component { samlEnabled: global.window.mm_license.IsLicensed === 'true' && global.window.mm_config.EnableSaml === 'true', loginId: '', // the browser will set a default for this password: '', - showMfa: false + showMfa: false, + loading: false }; } @@ -117,40 +120,38 @@ export default class LoginController extends React.Component { return; } - if (global.window.mm_config.EnableMultifactorAuthentication === 'true') { - Client.checkMfa( - loginId, - (data) => { - if (data.mfa_required === 'true') { - this.setState({showMfa: true}); - } else { - this.submit(loginId, password, ''); - } - }, - (err) => { - this.setState({serverError: err.message}); + checkMfa( + loginId, + (data) => { + if (data && data.mfa_required === 'true') { + this.setState({showMfa: true}); + } else { + this.submit(loginId, password, ''); } - ); - } else { - this.submit(loginId, password, ''); - } + }, + (err) => { + this.setState({serverError: err.message}); + } + ); } submit(loginId, password, token) { - this.setState({serverError: null}); + this.setState({serverError: null, loading: true}); - Client.webLogin( + webLogin( loginId, password, token, () => { // check for query params brought over from signup_user_complete - const query = this.props.location.query; - if (query.id || query.h) { - Client.addUserToTeamFromInvite( - query.d, - query.h, - query.id, + const hash = this.props.location.query.h; + const data = this.props.location.query.d; + const inviteId = this.props.location.query.id; + if (inviteId || hash) { + addUserToTeamFromInvite( + data, + hash, + inviteId, (team) => { this.finishSignin(team); }, @@ -172,6 +173,7 @@ export default class LoginController extends React.Component { err.id === 'ent.ldap.do_login.user_not_registered.app_error') { this.setState({ showMfa: false, + loading: false, serverError: ( <FormattedMessage id='login.userNotFound' @@ -182,6 +184,7 @@ export default class LoginController extends React.Component { } else if (err.id === 'api.user.check_user_password.invalid.app_error' || err.id === 'ent.ldap.do_login.invalid_password.app_error') { this.setState({ showMfa: false, + loading: false, serverError: ( <FormattedMessage id='login.invalidPassword' @@ -190,7 +193,7 @@ export default class LoginController extends React.Component { ) }); } else { - this.setState({showMfa: false, serverError: err.message}); + this.setState({showMfa: false, serverError: err.message, loading: false}); } } ); @@ -348,6 +351,23 @@ export default class LoginController extends React.Component { errorClass = ' has-error'; } + let loginButton = + (<FormattedMessage + id='login.signIn' + defaultMessage='Sign in' + />); + + if (this.state.loading) { + loginButton = + (<span> + <span className='fa fa-refresh icon--rotate'/> + <FormattedMessage + id='login.signInLoading' + defaultMessage='Signing in...' + /> + </span>); + } + loginControls.push( <form key='loginBoxes' @@ -387,10 +407,7 @@ export default class LoginController extends React.Component { type='submit' className='btn btn-primary' > - <FormattedMessage - id='login.signIn' - defaultMessage='Sign in' - /> + { loginButton } </button> </div> </div> diff --git a/webapp/components/member_list_channel.jsx b/webapp/components/member_list_channel.jsx new file mode 100644 index 000000000..6f8a266ad --- /dev/null +++ b/webapp/components/member_list_channel.jsx @@ -0,0 +1,179 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelMembersDropdown from 'components/channel_members_dropdown.jsx'; +import SearchableUserList from 'components/searchable_user_list.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; +import UserStore from 'stores/user_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; + +import {searchUsers, loadProfilesAndTeamMembersAndChannelMembers, loadTeamMembersAndChannelMembersForProfilesList} from 'actions/user_actions.jsx'; +import {getChannelStats} from 'utils/async_client.jsx'; + +import Constants from 'utils/constants.jsx'; + +import * as UserAgent from 'utils/user_agent.jsx'; + +import React from 'react'; + +const USERS_PER_PAGE = 50; + +export default class MemberListChannel extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + this.onStatsChange = this.onStatsChange.bind(this); + this.search = this.search.bind(this); + this.loadComplete = this.loadComplete.bind(this); + + this.searchTimeoutId = 0; + + const stats = ChannelStore.getCurrentStats(); + + this.state = { + users: UserStore.getProfileListInChannel(), + teamMembers: Object.assign({}, TeamStore.getMembersInTeam()), + channelMembers: Object.assign({}, ChannelStore.getMembersInChannel()), + total: stats.member_count, + search: false, + term: '', + loading: true + }; + } + + componentDidMount() { + UserStore.addInTeamChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); + TeamStore.addChangeListener(this.onChange); + ChannelStore.addChangeListener(this.onChange); + ChannelStore.addStatsChangeListener(this.onStatsChange); + + loadProfilesAndTeamMembersAndChannelMembers(0, Constants.PROFILE_CHUNK_SIZE, TeamStore.getCurrentId(), ChannelStore.getCurrentId(), this.loadComplete); + getChannelStats(ChannelStore.getCurrentId()); + } + + componentWillUnmount() { + UserStore.removeInTeamChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); + TeamStore.removeChangeListener(this.onChange); + ChannelStore.removeChangeListener(this.onChange); + ChannelStore.removeStatsChangeListener(this.onStatsChange); + } + + loadComplete() { + this.setState({loading: false}); + } + + onChange(force) { + if (this.state.search && !force) { + return; + } else if (this.state.search) { + this.search(this.state.term); + return; + } + + this.setState({ + users: UserStore.getProfileListInChannel(), + teamMembers: Object.assign({}, TeamStore.getMembersInTeam()), + channelMembers: Object.assign({}, ChannelStore.getMembersInChannel()) + }); + } + + onStatsChange() { + const stats = ChannelStore.getCurrentStats(); + this.setState({total: stats.member_count}); + } + + nextPage(page) { + loadProfilesAndTeamMembersAndChannelMembers((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } + + search(term) { + if (term === '') { + this.setState({ + search: false, + term, + users: UserStore.getProfileListInChannel(), + teamMembers: Object.assign([], TeamStore.getMembersInTeam()), + channelMembers: Object.assign([], ChannelStore.getMembersInChannel()) + }); + return; + } + + clearTimeout(this.searchTimeoutId); + + const searchTimeoutId = setTimeout( + () => { + searchUsers( + term, + TeamStore.getCurrentId(), + {}, + (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } + + this.setState({ + loading: true, + search: true, + users, + term, + teamMembers: Object.assign([], TeamStore.getMembersInTeam()), + channelMembers: Object.assign([], ChannelStore.getMembersInChannel()) + }); + loadTeamMembersAndChannelMembersForProfilesList(users, TeamStore.getCurrentId(), ChannelStore.getCurrentId(), this.loadComplete); + } + ); + }, + Constants.SEARCH_TIMEOUT_MILLISECONDS + ); + + this.searchTimeoutId = searchTimeoutId; + } + + render() { + const teamMembers = this.state.teamMembers; + const channelMembers = this.state.channelMembers; + const users = this.state.users; + const actionUserProps = {}; + + let usersToDisplay; + if (this.state.loading) { + usersToDisplay = null; + } else { + usersToDisplay = []; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + + if (teamMembers[user.id] && channelMembers[user.id]) { + usersToDisplay.push(user); + actionUserProps[user.id] = { + channel: this.props.channel, + teamMember: teamMembers[user.id], + channelMember: channelMembers[user.id] + }; + } + } + } + + return ( + <SearchableUserList + users={usersToDisplay} + usersPerPage={USERS_PER_PAGE} + total={this.state.total} + nextPage={this.nextPage} + search={this.search} + actions={[ChannelMembersDropdown]} + actionUserProps={actionUserProps} + focusOnMount={!UserAgent.isMobile()} + /> + ); + } +} + +MemberListChannel.propTypes = { + channel: React.PropTypes.object.isRequired +}; diff --git a/webapp/components/member_list_team.jsx b/webapp/components/member_list_team.jsx index a9db0e734..df17d7df2 100644 --- a/webapp/components/member_list_team.jsx +++ b/webapp/components/member_list_team.jsx @@ -23,6 +23,7 @@ export default class MemberListTeam extends React.Component { super(props); this.onChange = this.onChange.bind(this); + this.onTeamChange = this.onTeamChange.bind(this); this.onStatsChange = this.onStatsChange.bind(this); this.search = this.search.bind(this); this.loadComplete = this.loadComplete.bind(this); @@ -44,7 +45,7 @@ export default class MemberListTeam extends React.Component { componentDidMount() { UserStore.addInTeamChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); - TeamStore.addChangeListener(this.onChange.bind(null, true)); + TeamStore.addChangeListener(this.onTeamChange); TeamStore.addStatsChangeListener(this.onStatsChange); loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, TeamStore.getCurrentId(), this.loadComplete); @@ -54,7 +55,7 @@ export default class MemberListTeam extends React.Component { componentWillUnmount() { UserStore.removeInTeamChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); - TeamStore.removeChangeListener(this.onChange); + TeamStore.removeChangeListener(this.onTeamChange); TeamStore.removeStatsChangeListener(this.onStatsChange); } @@ -62,6 +63,10 @@ export default class MemberListTeam extends React.Component { this.setState({loading: false}); } + onTeamChange() { + this.onChange(true); + } + onChange(force) { if (this.state.search && !force) { return; @@ -90,13 +95,16 @@ export default class MemberListTeam extends React.Component { clearTimeout(this.searchTimeoutId); - this.searchTimeoutId = setTimeout( + const searchTimeoutId = setTimeout( () => { searchUsers( term, TeamStore.getCurrentId(), {}, (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } this.setState({loading: true, search: true, users, term, teamMembers: Object.assign([], TeamStore.getMembersInTeam())}); loadTeamMembersForProfilesList(users, TeamStore.getCurrentId(), this.loadComplete); } @@ -104,6 +112,8 @@ export default class MemberListTeam extends React.Component { }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); + + this.searchTimeoutId = searchTimeoutId; } render() { diff --git a/webapp/components/mfa/mfa_controller.jsx b/webapp/components/mfa/mfa_controller.jsx index 21b9737f8..cd9497985 100644 --- a/webapp/components/mfa/mfa_controller.jsx +++ b/webapp/components/mfa/mfa_controller.jsx @@ -1,6 +1,8 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {emitUserLoggedOutEvent} from 'actions/global_actions.jsx'; + import React from 'react'; import {FormattedMessage} from 'react-intl'; import {browserHistory, Link} from 'react-router/es6'; @@ -16,13 +18,32 @@ export default class MFAController extends React.Component { render() { let backButton; - if (window.mm_config.EnforceMultifactorAuthentication !== 'true') { + if (window.mm_config.EnforceMultifactorAuthentication === 'true') { + backButton = ( + <div className='signup-header'> + <a + href='#' + onClick={(e) => { + e.preventDefault(); + emitUserLoggedOutEvent('/login'); + }} + > + <span className='fa fa-chevron-left'/> + <FormattedMessage + id='web.header.logout' + defaultMessage='Logout' + /> + </a> + </div> + ); + } else { backButton = ( <div className='signup-header'> <Link to='/'> <span className='fa fa-chevron-left'/> <FormattedMessage id='web.header.back' + defaultMessage='Back' /> </Link> </div> diff --git a/webapp/components/more_channels.jsx b/webapp/components/more_channels.jsx index e4cff451d..d0b5f5399 100644 --- a/webapp/components/more_channels.jsx +++ b/webapp/components/more_channels.jsx @@ -107,17 +107,22 @@ export default class MoreChannels extends React.Component { clearTimeout(this.searchTimeoutId); - this.searchTimeoutId = setTimeout( + const searchTimeoutId = setTimeout( () => { searchMoreChannels( term, (channels) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } this.setState({search: true, channels}); } ); }, SEARCH_TIMEOUT_MILLISECONDS ); + + this.searchTimeoutId = searchTimeoutId; } render() { @@ -196,4 +201,4 @@ export default class MoreChannels extends React.Component { MoreChannels.propTypes = { onModalDismissed: React.PropTypes.func, handleNewChannel: React.PropTypes.func -};
\ No newline at end of file +}; diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index 338d4edd1..b54b8701e 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -22,8 +22,6 @@ import PreferenceStore from 'stores/preference_store.jsx'; import ChannelSwitchModal from './channel_switch_modal.jsx'; -import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; import * as ChannelActions from 'actions/channel_actions.jsx'; @@ -37,7 +35,7 @@ import {FormattedMessage} from 'react-intl'; import {Popover, OverlayTrigger} from 'react-bootstrap'; -import {Link, browserHistory} from 'react-router/es6'; +import {Link} from 'react-router/es6'; import React from 'react'; @@ -111,23 +109,7 @@ export default class Navbar extends React.Component { } handleLeave() { - var channelId = this.state.channel.id; - - Client.leaveChannel(channelId, - () => { - ChannelActions.loadChannelsForCurrentUser(); - - if (this.state.isFavorite) { - ChannelActions.unmarkFavorite(channelId); - } - - const townsquare = ChannelStore.getByName('town-square'); - browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + townsquare.name); - }, - (err) => { - AsyncClient.dispatchError(err, 'handleLeave'); - } - ); + ChannelActions.leaveChannel(this.state.channel.id); } hideSidebars(e) { diff --git a/webapp/components/new_channel_modal.jsx b/webapp/components/new_channel_modal.jsx index fc9fd0295..be97532dc 100644 --- a/webapp/components/new_channel_modal.jsx +++ b/webapp/components/new_channel_modal.jsx @@ -272,7 +272,7 @@ export default class NewChannelModal extends React.Component { className='form-control no-resize' ref='channel_purpose' rows='4' - placeholder={Utils.localizeMessage('channel_modal.purpose', 'Purpose')} + placeholder={Utils.localizeMessage('channel_modal.purposeEx', 'E.g.: "A channel to file bugs and improvements"')} maxLength='250' value={this.props.channelData.purpose} onChange={this.handleChange} @@ -309,7 +309,7 @@ export default class NewChannelModal extends React.Component { className='form-control no-resize' ref='channel_header' rows='4' - placeholder={Utils.localizeMessage('channel_modal.header', 'Header')} + placeholder={Utils.localizeMessage('channel_modal.headerEx', 'E.g.: "[Link Title](http://example.com)"')} maxLength='128' value={this.props.channelData.header} onChange={this.handleChange} diff --git a/webapp/components/password_reset_form.jsx b/webapp/components/password_reset_form.jsx index b37e07f2d..c6fe2525f 100644 --- a/webapp/components/password_reset_form.jsx +++ b/webapp/components/password_reset_form.jsx @@ -2,12 +2,12 @@ // See License.txt for license information. import ReactDOM from 'react-dom'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; import {FormattedMessage} from 'react-intl'; -import {browserHistory} from 'react-router/es6'; + +import {resetPassword} from 'actions/user_actions.jsx'; import React from 'react'; @@ -42,12 +42,11 @@ class PasswordResetForm extends React.Component { error: null }); - Client.resetPassword( + resetPassword( this.props.location.query.code, password, () => { this.setState({error: null}); - browserHistory.push('/login?extra=' + Constants.PASSWORD_CHANGE); }, (err) => { this.setState({error: err.message}); diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index 9cea3922a..5ffcb687a 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -5,6 +5,11 @@ import ProfilePicture from 'components/profile_picture.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import TeamMembersModal from './team_members_modal.jsx'; +import ChannelMembersModal from './channel_members_modal.jsx'; +import ChannelInviteModal from './channel_invite_modal.jsx'; import {openDirectChannelToUser} from 'actions/channel_actions.jsx'; @@ -22,10 +27,17 @@ export default class PopoverListMembers extends React.Component { constructor(props) { super(props); + this.showMembersModal = this.showMembersModal.bind(this); + this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); this.closePopover = this.closePopover.bind(this); - this.state = {showPopover: false}; + this.state = { + showPopover: false, + showTeamMembersModal: false, + showChannelMembersModal: false, + showChannelInviteModal: false + }; } componentDidUpdate() { @@ -53,12 +65,31 @@ export default class PopoverListMembers extends React.Component { this.setState({showPopover: false}); } + showMembersModal(e) { + e.preventDefault(); + + if (ChannelStore.isDefault(this.props.channel)) { + this.setState({ + showPopover: false, + showTeamMembersModal: true + }); + } else { + this.setState({ + showPopover: false, + showChannelMembersModal: true + }); + } + } + render() { const popoverHtml = []; const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); + let isAdmin = false; const currentUserId = UserStore.getCurrentId(); + isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); + if (members && teamMembers) { members.sort((a, b) => { const aName = Utils.displayUsername(a.id); @@ -96,7 +127,7 @@ export default class PopoverListMembers extends React.Component { key={'popover-member-' + i} > <ProfilePicture - src={`${Client.getUsersRoute()}/${m.id}/image?time=${m.update_at}`} + src={`${Client.getUsersRoute()}/${m.id}/image?time=${m.last_picture_update}`} width='26' height='26' /> @@ -117,17 +148,37 @@ export default class PopoverListMembers extends React.Component { } }); + let membersName = ( + <FormattedMessage + id='members_popover.manageMembers' + defaultMessage='Manage Members' + /> + ); + if (!isAdmin && ChannelStore.isDefault(this.props.channel)) { + membersName = ( + <FormattedMessage + id='members_popover.viewMembers' + defaultMessage='View Members' + /> + ); + } + popoverHtml.push( <div className='more-modal__row' key={'popover-member-more'} > - <div className='col-sm-5'/> + <div className='col-sm-3'/> <div className='more-modal__details'> <div className='more-modal__name' > - {'...'} + <a + href='#' + onClick={this.showMembersModal} + > + {membersName} + </a> </div> </div> </div> @@ -146,6 +197,38 @@ export default class PopoverListMembers extends React.Component { defaultMessage='Members' /> ); + + let channelMembersModal; + if (this.state.showChannelMembersModal) { + channelMembersModal = ( + <ChannelMembersModal + onModalDismissed={() => this.setState({showChannelMembersModal: false})} + showInviteModal={() => this.setState({showChannelInviteModal: true})} + channel={this.props.channel} + /> + ); + } + + let teamMembersModal; + if (this.state.showTeamMembersModal) { + teamMembersModal = ( + <TeamMembersModal + onHide={() => this.setState({showTeamMembersModal: false})} + isAdmin={isAdmin} + /> + ); + } + + let channelInviteModal; + if (this.state.showChannelInviteModal) { + channelInviteModal = ( + <ChannelInviteModal + onHide={() => this.setState({showChannelInviteModal: false})} + channel={this.props.channel} + /> + ); + } + return ( <div> <div @@ -181,6 +264,9 @@ export default class PopoverListMembers extends React.Component { <div className='more-modal__list'>{popoverHtml}</div> </Popover> </Overlay> + {channelMembersModal} + {teamMembersModal} + {channelInviteModal} </div> ); } diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx index f052ac4ae..896002a6c 100644 --- a/webapp/components/post_view/components/post.jsx +++ b/webapp/components/post_view/components/post.jsx @@ -150,10 +150,10 @@ export default class Post extends React.Component { } let timestamp = 0; - if (!this.props.user || this.props.user.update_at == null) { - timestamp = this.props.currentUser.update_at; + if (!this.props.user || this.props.user.last_picture_update == null) { + timestamp = this.props.currentUser.last_picture_update; } else { - timestamp = this.props.user.update_at; + timestamp = this.props.user.last_picture_update; } let sameUserClass = ''; @@ -250,7 +250,11 @@ export default class Post extends React.Component { } return ( - <div> + <div + ref={(div) => { + this.domNode = div; + }} + > <div id={'post_' + post.id} className={'post ' + sameUserClass + ' ' + compactClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss + ' ' + shouldHighlightClass + ' ' + systemMessageClass + ' ' + hideControls + ' ' + dropdownOpenedClass} @@ -285,6 +289,7 @@ export default class Post extends React.Component { compactDisplay={this.props.compactDisplay} previewCollapsed={this.props.previewCollapsed} isCommentMention={this.props.isCommentMention} + childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} /> </div> </div> @@ -313,5 +318,6 @@ Post.propTypes = { useMilitaryTime: React.PropTypes.bool.isRequired, isFlagged: React.PropTypes.bool, status: React.PropTypes.string, - isBusy: React.PropTypes.bool + isBusy: React.PropTypes.bool, + childComponentDidUpdateFunction: React.PropTypes.func }; diff --git a/webapp/components/post_view/components/post_attachment_oembed.jsx b/webapp/components/post_view/components/post_attachment_oembed.jsx deleted file mode 100644 index 359c7cc35..000000000 --- a/webapp/components/post_view/components/post_attachment_oembed.jsx +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import $ from 'jquery'; -import React from 'react'; - -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: {}}); - } - }); - } - return null; - } - - 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' - rel='noopener noreferrer' - > - {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/webapp/components/post_view/components/post_attachment_opengraph.jsx b/webapp/components/post_view/components/post_attachment_opengraph.jsx new file mode 100644 index 000000000..20beaed51 --- /dev/null +++ b/webapp/components/post_view/components/post_attachment_opengraph.jsx @@ -0,0 +1,212 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import OpenGraphStore from 'stores/opengraph_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import * as CommonUtils from 'utils/commons.jsx'; +import {requestOpenGraphMetadata} from 'actions/global_actions.jsx'; + +export default class PostAttachmentOpenGraph extends React.Component { + constructor(props) { + super(props); + this.imageDimentions = { // Image dimentions in pixels. + height: 150, + width: 150 + }; + this.maxDescriptionLength = 300; + this.descriptionEllipsis = '...'; + this.fetchData = this.fetchData.bind(this); + this.onOpenGraphMetadataChange = this.onOpenGraphMetadataChange.bind(this); + this.toggleImageVisibility = this.toggleImageVisibility.bind(this); + this.onImageLoad = this.onImageLoad.bind(this); + } + + componentWillMount() { + this.setState({ + data: {}, + imageLoaded: false, + imageVisible: this.props.previewCollapsed.startsWith('false') + }); + this.fetchData(this.props.link); + } + + componentWillReceiveProps(nextProps) { + this.setState({imageVisible: nextProps.previewCollapsed.startsWith('false')}); + if (!Utils.areObjectsEqual(nextProps.link, this.props.link)) { + this.fetchData(nextProps.link); + } + } + + shouldComponentUpdate(nextProps, nextState) { + if (nextState.imageVisible !== this.state.imageVisible) { + return true; + } + if (nextState.imageLoaded !== this.state.imageLoaded) { + return true; + } + if (!Utils.areObjectsEqual(nextState.data, this.state.data)) { + return true; + } + return false; + } + + componentDidMount() { + OpenGraphStore.addUrlDataChangeListener(this.onOpenGraphMetadataChange); + } + + componentDidUpdate() { + if (this.props.childComponentDidUpdateFunction) { + this.props.childComponentDidUpdateFunction(); + } + } + + componentWillUnmount() { + OpenGraphStore.removeUrlDataChangeListener(this.onOpenGraphMetadataChange); + } + + onOpenGraphMetadataChange(url) { + if (url === this.props.link) { + this.fetchData(url); + } + } + + fetchData(url) { + const data = OpenGraphStore.getOgInfo(url); + this.setState({data, imageLoaded: false}); + if (Utils.isEmptyObject(data)) { + requestOpenGraphMetadata(url); + } + } + + getBestImageUrl() { + const nearestPointData = CommonUtils.getNearestPoint(this.imageDimentions, this.state.data.images, 'width', 'height'); + + const bestImage = nearestPointData.nearestPoint; + const bestImageLte = nearestPointData.nearestPointLte; // Best image <= 150px height and width + + let finalBestImage; + + if ( + !Utils.isEmptyObject(bestImageLte) && + bestImageLte.height <= this.imageDimentions.height && + bestImageLte.width <= this.imageDimentions.width + ) { + finalBestImage = bestImageLte; + } else { + finalBestImage = bestImage; + } + + return finalBestImage.secure_url || finalBestImage.url; + } + + toggleImageVisibility() { + this.setState({imageVisible: !this.state.imageVisible}); + } + + onImageLoad() { + this.setState({imageLoaded: true}); + } + + loadImage(src) { + const img = new Image(); + img.onload = this.onImageLoad; + img.src = src; + } + + imageToggleAnchoreTag(imageUrl) { + if (imageUrl) { + return ( + <a + className={'post__embed-visibility'} + data-expanded={this.state.imageVisible} + aria-label='Toggle Embed Visibility' + onClick={this.toggleImageVisibility} + /> + ); + } + return null; + } + + imageTag(imageUrl) { + if (imageUrl && this.state.imageVisible) { + return ( + <img + className={this.state.imageLoaded ? 'attachment__image' : 'attachment__image loading'} + src={this.state.imageLoaded ? imageUrl : null} + /> + ); + } + return null; + } + + render() { + if (Utils.isEmptyObject(this.state.data) || Utils.isEmptyObject(this.state.data.description)) { + return null; + } + + const data = this.state.data; + const imageUrl = this.getBestImageUrl(); + var description = data.description; + + if (description.length > this.maxDescriptionLength) { + description = description.substring(0, this.maxDescriptionLength - this.descriptionEllipsis.length) + this.descriptionEllipsis; + } + + if (imageUrl && this.state.imageVisible) { + this.loadImage(imageUrl); + } + + return ( + <div + className='attachment attachment--oembed' + ref='attachment' + > + <div className='attachment__content'> + <div + className={'clearfix attachment__container'} + > + <span className='sitename'>{data.site_name}</span> + <h1 + className='attachment__title has-link' + > + <a + className='attachment__title-link' + href={data.url || this.props.link} + target='_blank' + rel='noopener noreferrer' + title={data.title || data.url || this.props.link} + > + {data.title || data.url || this.props.link} + </a> + </h1> + <div > + <div + className={'attachment__body attachment__body--no_thumb'} + > + <div> + <div> + {description} + {this.imageToggleAnchoreTag(imageUrl)} + </div> + {this.imageTag(imageUrl)} + </div> + </div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachmentOpenGraph.defaultProps = { + previewCollapsed: 'false' +}; + +PostAttachmentOpenGraph.propTypes = { + link: React.PropTypes.string.isRequired, + childComponentDidUpdateFunction: React.PropTypes.func, + previewCollapsed: React.PropTypes.string +}; diff --git a/webapp/components/post_view/components/post_body.jsx b/webapp/components/post_view/components/post_body.jsx index 60e682e8d..10c24aab2 100644 --- a/webapp/components/post_view/components/post_body.jsx +++ b/webapp/components/post_view/components/post_body.jsx @@ -188,6 +188,7 @@ export default class PostBody extends React.Component { message={messageWrapper} compactDisplay={this.props.compactDisplay} previewCollapsed={this.props.previewCollapsed} + childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} /> ); } @@ -221,5 +222,6 @@ PostBody.propTypes = { handleCommentClick: React.PropTypes.func.isRequired, compactDisplay: React.PropTypes.bool, previewCollapsed: React.PropTypes.string, - isCommentMention: React.PropTypes.bool + isCommentMention: React.PropTypes.bool, + childComponentDidUpdateFunction: React.PropTypes.func }; diff --git a/webapp/components/post_view/components/post_body_additional_content.jsx b/webapp/components/post_view/components/post_body_additional_content.jsx index a65b608d7..cad618de0 100644 --- a/webapp/components/post_view/components/post_body_additional_content.jsx +++ b/webapp/components/post_view/components/post_body_additional_content.jsx @@ -2,12 +2,11 @@ // See License.txt for license information. import PostAttachmentList from './post_attachment_list.jsx'; -import PostAttachmentOEmbed from './post_attachment_oembed.jsx'; +import PostAttachmentOpenGraph from './post_attachment_opengraph.jsx'; import PostImage from './post_image.jsx'; import YoutubeVideo from 'components/youtube_video.jsx'; import Constants from 'utils/constants.jsx'; -import OEmbedProviders from './providers.json'; import * as Utils from 'utils/utils.jsx'; import React from 'react'; @@ -17,22 +16,24 @@ export default class PostBodyAdditionalContent extends React.Component { super(props); this.getSlackAttachment = this.getSlackAttachment.bind(this); - this.getOEmbedProvider = this.getOEmbedProvider.bind(this); this.generateToggleableEmbed = this.generateToggleableEmbed.bind(this); this.generateStaticEmbed = this.generateStaticEmbed.bind(this); this.toggleEmbedVisibility = this.toggleEmbedVisibility.bind(this); this.isLinkToggleable = this.isLinkToggleable.bind(this); + this.handleLinkLoadError = this.handleLinkLoadError.bind(this); this.state = { embedVisible: props.previewCollapsed.startsWith('false'), - link: Utils.extractFirstLink(props.post.message) + link: Utils.extractFirstLink(props.post.message), + linkLoadError: false }; } componentWillReceiveProps(nextProps) { this.setState({ embedVisible: nextProps.previewCollapsed.startsWith('false'), - link: Utils.extractFirstLink(nextProps.post.message) + link: Utils.extractFirstLink(nextProps.post.message), + linkLoadError: false }); } @@ -46,6 +47,9 @@ export default class PostBodyAdditionalContent extends React.Component { if (nextState.embedVisible !== this.state.embedVisible) { return true; } + if (nextState.linkLoadError !== this.state.linkLoadError) { + return true; + } return false; } @@ -66,25 +70,11 @@ export default class PostBodyAdditionalContent extends React.Component { ); } - getOEmbedProvider(link) { - for (let i = 0; i < OEmbedProviders.length; i++) { - for (let j = 0; j < OEmbedProviders[i].patterns.length; j++) { - if (link.match(OEmbedProviders[i].patterns[j])) { - return OEmbedProviders[i]; - } - } - } - - return null; - } - isLinkImage(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 true; - } + const regex = /.+\/(.+\.(?:jpg|gif|bmp|png|jpeg))(?:\?.*)?$/i; + const match = link.match(regex); + if (match && match[1]) { + return true; } return false; @@ -107,6 +97,12 @@ export default class PostBodyAdditionalContent extends React.Component { return false; } + handleLinkLoadError() { + this.setState({ + linkLoadError: true + }); + } + generateToggleableEmbed() { const link = this.state.link; if (!link) { @@ -128,6 +124,7 @@ export default class PostBodyAdditionalContent extends React.Component { <PostImage channelId={this.props.post.channel_id} link={link} + onLinkLoadError={this.handleLinkLoadError} /> ); } @@ -141,39 +138,21 @@ export default class PostBodyAdditionalContent extends React.Component { } const link = Utils.extractFirstLink(this.props.post.message); - if (!link) { - return null; - } - - if (Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMBED_PREVIEW)) { - const provider = this.getOEmbedProvider(link); - - if (provider) { - return ( - <PostAttachmentOEmbed - provider={provider} - link={link} - /> - ); - } + if (link && Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMBED_PREVIEW)) { + return ( + <PostAttachmentOpenGraph + link={link} + childComponentDidUpdateFunction={this.props.childComponentDidUpdateFunction} + previewCollapsed={this.props.previewCollapsed} + /> + ); } return null; } render() { - const staticEmbed = this.generateStaticEmbed(); - - if (staticEmbed) { - return ( - <div> - {this.props.message} - {staticEmbed} - </div> - ); - } - - if (this.isLinkToggleable()) { + if (this.isLinkToggleable() && !this.state.linkLoadError) { const messageWithToggle = []; // if message has only one line and starts with a link place toggle in this only line @@ -213,6 +192,17 @@ export default class PostBodyAdditionalContent extends React.Component { ); } + const staticEmbed = this.generateStaticEmbed(); + + if (staticEmbed) { + return ( + <div> + {this.props.message} + {staticEmbed} + </div> + ); + } + return this.props.message; } } @@ -224,5 +214,6 @@ PostBodyAdditionalContent.propTypes = { post: React.PropTypes.object.isRequired, message: React.PropTypes.element.isRequired, compactDisplay: React.PropTypes.bool, - previewCollapsed: React.PropTypes.string + previewCollapsed: React.PropTypes.string, + childComponentDidUpdateFunction: React.PropTypes.func }; diff --git a/webapp/components/post_view/components/post_image.jsx b/webapp/components/post_view/components/post_image.jsx index d1d1a6c7a..9a761bfca 100644 --- a/webapp/components/post_view/components/post_image.jsx +++ b/webapp/components/post_view/components/post_image.jsx @@ -53,6 +53,9 @@ export default class PostImageEmbed extends React.Component { errored: true, loaded: true }); + if (this.props.onLinkLoadError) { + this.props.onLinkLoadError(); + } } render() { @@ -79,5 +82,6 @@ export default class PostImageEmbed extends React.Component { } PostImageEmbed.propTypes = { - link: React.PropTypes.string.isRequired + link: React.PropTypes.string.isRequired, + onLinkLoadError: React.PropTypes.func }; diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/components/post_info.jsx index aa204add1..3f38bdffe 100644 --- a/webapp/components/post_view/components/post_info.jsx +++ b/webapp/components/post_view/components/post_info.jsx @@ -8,12 +8,10 @@ import PostTime from './post_time.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import * as PostActions from 'actions/post_actions.jsx'; -import TeamStore from 'stores/team_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import React from 'react'; @@ -23,31 +21,42 @@ export default class PostInfo extends React.Component { constructor(props) { super(props); - this.handleDropdownClick = this.handleDropdownClick.bind(this); + this.handleDropdownOpened = this.handleDropdownOpened.bind(this); this.handlePermalink = this.handlePermalink.bind(this); this.removePost = this.removePost.bind(this); this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + + this.canEdit = false; + this.canDelete = false; + this.editDisableAction = new DelayedAction(this.handleEditDisable); } - handleDropdownClick(e) { - var position = $('#post-list').height() - $(e.target).offset().top; - var dropdown = $(e.target).closest('.col__reply').find('.dropdown-menu'); + handleDropdownOpened() { + this.props.handleDropdownOpened(true); + + const position = $('#post-list').height() - $(this.refs.dropdownToggle).offset().top; + const dropdown = $(this.refs.dropdown); + if (position < dropdown.height()) { dropdown.addClass('bottom'); } } + handleEditDisable() { + this.canEdit = false; + } + componentDidMount() { - $('#post_dropdown' + this.props.post.id).on('shown.bs.dropdown', () => this.props.handleDropdownOpened(true)); + $('#post_dropdown' + this.props.post.id).on('shown.bs.dropdown', this.handleDropdownOpened); $('#post_dropdown' + this.props.post.id).on('hidden.bs.dropdown', () => this.props.handleDropdownOpened(false)); } createDropdown() { var post = this.props.post; - var isOwner = this.props.currentUser.id === post.user_id; - var isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); - const isSystemMessage = post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX); + + this.canDelete = PostUtils.canDeletePost(post); + this.canEdit = PostUtils.canEditPost(post, this.editDisableAction); if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING) { return ''; @@ -139,7 +148,7 @@ export default class PostInfo extends React.Component { </li> ); - if (isOwner || isAdmin) { + if (this.canDelete) { dropdownContents.push( <li key='deletePost' @@ -162,12 +171,12 @@ export default class PostInfo extends React.Component { ); } - if (isOwner && !isSystemMessage) { + if (this.canEdit) { dropdownContents.push( <li key='editPost' role='presentation' - className='dropdown-submenu' + className={this.canEdit ? 'dropdown-submenu' : 'dropdown-submenu hide'} > <a href='#' @@ -199,15 +208,16 @@ export default class PostInfo extends React.Component { id={'post_dropdown' + this.props.post.id} > <a + ref='dropdownToggle' href='#' className='dropdown-toggle post__dropdown theme' type='button' data-toggle='dropdown' aria-expanded='false' - onClick={this.handleDropdownClick} /> <div className='dropdown-menu__content'> <ul + ref='dropdown' className='dropdown-menu' role='menu' > diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx index 29358122b..7550db348 100644 --- a/webapp/components/post_view/components/post_list.jsx +++ b/webapp/components/post_view/components/post_list.jsx @@ -45,6 +45,7 @@ export default class PostList extends React.Component { this.scrollToBottom = this.scrollToBottom.bind(this); this.scrollToBottomAnimated = this.scrollToBottomAnimated.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + this.childComponentDidUpdate = this.childComponentDidUpdate.bind(this); this.jumpToPostNode = null; this.wasAtBottom = true; @@ -159,7 +160,7 @@ export default class PostList extends React.Component { const id = this.props.postList.order[i]; const element = this.refs[id]; - if (!element || element.offsetTop + element.clientHeight <= this.refs.postlist.scrollTop) { + if (!element || !element.domNode || element.domNode.offsetTop + element.domNode.clientHeight <= this.refs.postlist.scrollTop) { // this post is off the top of the screen so the last one is at the top of the screen let topPostId; @@ -347,6 +348,7 @@ export default class PostList extends React.Component { isFlagged={isFlagged} status={status} isBusy={this.props.isBusy} + childComponentDidUpdateFunction={this.childComponentDidUpdate} /> ); @@ -421,6 +423,11 @@ export default class PostList extends React.Component { this.scrollToBottom(); } }); + + // This avoids the scroll jumping from top to bottom after the page has rendered (PLT-5025). + if (!this.refs.newMessageSeparator) { + this.scrollToBottom(); + } } else if (this.props.scrollType === ScrollTypes.POST && this.props.scrollPostId) { window.requestAnimationFrame(() => { const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]); @@ -487,6 +494,12 @@ export default class PostList extends React.Component { ); } + checkAndUpdateScrolling() { + if (this.props.postList != null && this.refs.postlist) { + this.updateScrolling(); + } + } + componentDidMount() { if (this.props.postList != null) { this.updateScrolling(); @@ -504,9 +517,11 @@ export default class PostList extends React.Component { } componentDidUpdate() { - if (this.props.postList != null && this.refs.postlist) { - this.updateScrolling(); - } + this.checkAndUpdateScrolling(); + } + + childComponentDidUpdate() { + this.checkAndUpdateScrolling(); } render() { diff --git a/webapp/components/post_view/components/post_message_container.jsx b/webapp/components/post_view/components/post_message_container.jsx index 2d17e74c4..4e27cd29a 100644 --- a/webapp/components/post_view/components/post_message_container.jsx +++ b/webapp/components/post_view/components/post_message_container.jsx @@ -89,7 +89,7 @@ export default class PostMessageContainer extends React.Component { return ( <PostMessageView options={this.props.options} - message={this.props.post.message} + post={this.props.post} emojis={this.state.emojis} enableFormatting={this.state.enableFormatting} mentionKeys={this.state.mentionKeys} diff --git a/webapp/components/post_view/components/post_message_view.jsx b/webapp/components/post_view/components/post_message_view.jsx index 24f96a8d9..eff791aec 100644 --- a/webapp/components/post_view/components/post_message_view.jsx +++ b/webapp/components/post_view/components/post_message_view.jsx @@ -2,14 +2,16 @@ // See License.txt for license information. import React from 'react'; +import {FormattedMessage} from 'react-intl'; import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as PostUtils from 'utils/post_utils.jsx'; export default class PostMessageView extends React.Component { static propTypes = { options: React.PropTypes.object.isRequired, - message: React.PropTypes.string.isRequired, + post: React.PropTypes.object.isRequired, emojis: React.PropTypes.object.isRequired, enableFormatting: React.PropTypes.bool.isRequired, mentionKeys: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, @@ -23,7 +25,7 @@ export default class PostMessageView extends React.Component { return true; } - if (nextProps.message !== this.props.message) { + if (nextProps.post.message !== this.props.post.message) { return true; } @@ -47,9 +49,28 @@ export default class PostMessageView extends React.Component { return false; } + editedIndicator() { + return ( + PostUtils.isEdited(this.props.post) ? + <span className='edited'> + <FormattedMessage + id='post_message_view.edited' + defaultMessage='(edited)' + /> + </span> : + '' + ); + } + render() { if (!this.props.enableFormatting) { - return <span>{this.props.message}</span>; + return ( + <span> + {this.props.post.message} + + {this.editedIndicator()} + </span> + ); } const options = Object.assign({}, this.props.options, { @@ -62,10 +83,13 @@ export default class PostMessageView extends React.Component { }); return ( - <span - onClick={Utils.handleFormattedTextClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.message, options)}} - /> + <div> + <span + onClick={Utils.handleFormattedTextClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, options)}} + /> + {this.editedIndicator()} + </div> ); } } diff --git a/webapp/components/post_view/components/post_time.jsx b/webapp/components/post_view/components/post_time.jsx index c8e57f6a9..caad12d4a 100644 --- a/webapp/components/post_view/components/post_time.jsx +++ b/webapp/components/post_view/components/post_time.jsx @@ -27,7 +27,10 @@ export default class PostTime extends React.Component { render() { return ( - <time className='post__time'> + <time + className='post__time' + dateTime={getDateForUnixTicks(this.props.eventTime).toISOString()} + > {getDateForUnixTicks(this.props.eventTime).toLocaleString('en', {hour: '2-digit', minute: '2-digit', hour12: !this.props.useMilitaryTime})} </time> ); diff --git a/webapp/components/post_view/components/providers.json b/webapp/components/post_view/components/providers.json deleted file mode 100644 index b5899c225..000000000 --- a/webapp/components/post_view/components/providers.json +++ /dev/null @@ -1,376 +0,0 @@ -[ - { - "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/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx index a18a73b86..a18d0ac38 100644 --- a/webapp/components/post_view/post_view_controller.jsx +++ b/webapp/components/post_view/post_view_controller.jsx @@ -229,8 +229,16 @@ export default class PostViewController extends React.Component { onPostListScroll(atBottom) { if (atBottom) { + let lastViewedBottom; const lastPost = PostStore.getLatestPost(this.state.channel.id); - this.setState({scrollType: ScrollTypes.BOTTOM, lastViewedBottom: lastPost.create_at || new Date().getTime()}); + + if (lastPost && lastPost.create_at) { + lastViewedBottom = lastPost.create_at; + } else { + lastViewedBottom = new Date().getTime(); + } + + this.setState({scrollType: ScrollTypes.BOTTOM, lastViewedBottom}); } else { this.setState({scrollType: ScrollTypes.FREE}); } diff --git a/webapp/components/profile_popover.jsx b/webapp/components/profile_popover.jsx index 7cb2f7261..22cf60004 100644 --- a/webapp/components/profile_popover.jsx +++ b/webapp/components/profile_popover.jsx @@ -83,6 +83,9 @@ export default class ProfilePopover extends React.Component { openDirectChannelToUser( user, (channel) => { + if (Utils.isMobile()) { + GlobalActions.emitCloseRightHandSide(); + } this.setState({loadingDMChannel: -1}); if (this.props.hide) { this.props.hide(); @@ -185,34 +188,34 @@ export default class ProfilePopover extends React.Component { const fullname = Utils.getFullName(this.props.user); if (fullname) { dataContent.push( - <div - data-toggle='tooltip' - title={fullname} - key='user-popover-fullname' + <OverlayTrigger + delayShow={Constants.WEBRTC_TIME_DELAY} + placement='top' + overlay={<Tooltip id='fullNameTooltip'>{fullname}</Tooltip>} > - <p - className='text-nowrap' + <div + className='overflow--ellipsis text-nowrap padding-bottom' > {fullname} - </p> - </div> + </div> + </OverlayTrigger> ); } if (this.props.user.position) { const position = this.props.user.position.substring(0, Constants.MAX_POSITION_LENGTH); dataContent.push( - <div - data-toggle='tooltip' - title={position} - key='user-popover-position' + <OverlayTrigger + delayShow={Constants.WEBRTC_TIME_DELAY} + placement='top' + overlay={<Tooltip id='positionTooltip'>{position}</Tooltip>} > - <p - className='text-nowrap' + <div + className='overflow--ellipsis text-nowrap padding-bottom' > {position} - </p> - </div> + </div> + </OverlayTrigger> ); } diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index 8b7642fd8..26659c7a1 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -9,9 +9,6 @@ import ProfilePicture from 'components/profile_picture.jsx'; import ReactionListContainer from 'components/post_view/components/reaction_list_container.jsx'; import RhsDropdown from 'components/rhs_dropdown.jsx'; -import TeamStore from 'stores/team_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - import * as GlobalActions from 'actions/global_actions.jsx'; import {flagPost, unflagPost} from 'actions/post_actions.jsx'; @@ -19,6 +16,7 @@ import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; @@ -36,6 +34,10 @@ export default class RhsComment extends React.Component { this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.canEdit = false; + this.canDelete = false; + this.editDisableAction = new DelayedAction(this.handleEditDisable); + this.state = {}; } @@ -44,6 +46,10 @@ export default class RhsComment extends React.Component { GlobalActions.showGetPostLinkModal(this.props.post); } + handleEditDisable() { + this.canEdit = false; + } + removePost() { GlobalActions.emitRemovePost(this.props.post); } @@ -110,8 +116,8 @@ export default class RhsComment extends React.Component { return ''; } - const isOwner = this.props.currentUser.id === post.user_id; - var isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); + this.canDelete = PostUtils.canDeletePost(post); + this.canEdit = PostUtils.canEditPost(post, this.editDisableAction); var dropdownContents = []; @@ -170,7 +176,7 @@ export default class RhsComment extends React.Component { </li> ); - if (isOwner || isAdmin) { + if (this.canDelete) { dropdownContents.push( <li role='presentation' @@ -193,11 +199,12 @@ export default class RhsComment extends React.Component { ); } - if (isOwner) { + if (this.canEdit) { dropdownContents.push( <li role='presentation' key='edit-button' + className={this.canEdit ? '' : 'hide'} > <a href='#' @@ -239,7 +246,7 @@ export default class RhsComment extends React.Component { currentUserCss = 'current--user'; } - var timestamp = this.props.currentUser.update_at; + var timestamp = this.props.currentUser.last_picture_update; let status = this.props.status; if (post.props && post.props.from_webhook === 'true') { @@ -471,7 +478,10 @@ export default class RhsComment extends React.Component { </li> {botIndicator} <li className='col'> - <time className='post__time'> + <time + className='post__time' + dateTime={Utils.getDateForUnixTicks(post.create_at).toISOString()} + > {Utils.getDateForUnixTicks(post.create_at).toLocaleString('en', timeOptions)} </time> {flagTrigger} diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index 95f5fc1ac..7d00e2322 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -11,7 +11,6 @@ import RhsDropdown from 'components/rhs_dropdown.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; -import TeamStore from 'stores/team_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {flagPost, unflagPost} from 'actions/post_actions.jsx'; @@ -20,6 +19,7 @@ import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; import Constants from 'utils/constants.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; @@ -34,6 +34,10 @@ export default class RhsRootPost extends React.Component { this.flagPost = this.flagPost.bind(this); this.unflagPost = this.unflagPost.bind(this); + this.canEdit = false; + this.canDelete = false; + this.editDisableAction = new DelayedAction(this.handleEditDisable); + this.state = {}; } @@ -42,6 +46,10 @@ export default class RhsRootPost extends React.Component { GlobalActions.showGetPostLinkModal(this.props.post); } + handleEditDisable() { + this.canEdit = false; + } + shouldComponentUpdate(nextProps) { if (nextProps.status !== this.props.status) { return true; @@ -96,13 +104,13 @@ export default class RhsRootPost extends React.Component { const post = this.props.post; const user = this.props.user; const mattermostLogo = Constants.MATTERMOST_ICON_SVG; - var isOwner = this.props.currentUser.id === post.user_id; - var isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); - const isSystemMessage = post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX); - var timestamp = user ? user.update_at : 0; + var timestamp = user ? user.last_picture_update : 0; var channel = ChannelStore.get(post.channel_id); const flagIcon = Constants.FLAG_ICON_SVG; + this.canDelete = PostUtils.canDeletePost(post); + this.canEdit = PostUtils.canEditPost(post, this.editDisableAction); + var type = 'Post'; if (post.root_id.length > 0) { type = 'Comment'; @@ -189,7 +197,7 @@ export default class RhsRootPost extends React.Component { </li> ); - if (isOwner || isAdmin) { + if (this.canDelete) { dropdownContents.push( <li key='rhs-root-delete' @@ -209,11 +217,12 @@ export default class RhsRootPost extends React.Component { ); } - if (isOwner && !isSystemMessage) { + if (this.canEdit) { dropdownContents.push( <li key='rhs-root-edit' role='presentation' + className={this.canEdit ? '' : 'hide'} > <a href='#' @@ -408,7 +417,10 @@ export default class RhsRootPost extends React.Component { <li className='col__name'>{userProfile}</li> {botIndicator} <li className='col'> - <time className='post__time'> + <time + className='post__time' + dateTime={Utils.getDateForUnixTicks(post.create_at).toISOString()} + > {Utils.getDateForUnixTicks(post.create_at).toLocaleString('en', timeOptions)} </time> <OverlayTrigger diff --git a/webapp/components/root.jsx b/webapp/components/root.jsx index be50c7d48..465df5d79 100644 --- a/webapp/components/root.jsx +++ b/webapp/components/root.jsx @@ -8,11 +8,12 @@ import Client from 'client/web_client.jsx'; import {IntlProvider} from 'react-intl'; import React from 'react'; - import FastClick from 'fastclick'; +import $ from 'jquery'; import {browserHistory} from 'react-router/es6'; import UserStore from 'stores/user_store.jsx'; +import BrowserStore from 'stores/browser_store.jsx'; export default class Root extends React.Component { constructor(props) { @@ -35,6 +36,30 @@ export default class Root extends React.Component { } /*eslint-enable */ + // Force logout of all tabs if one tab is logged out + $(window).bind('storage', (e) => { + // when one tab on a browser logs out, it sets __logout__ in localStorage to trigger other tabs to log out + if (e.originalEvent.key === '__logout__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { + // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) + if (BrowserStore.isSignallingLogout(e.originalEvent.newValue)) { + return; + } + + console.log('detected logout from a different tab'); //eslint-disable-line no-console + GlobalActions.emitUserLoggedOutEvent('/', false); + } + + if (e.originalEvent.key === '__login__' && e.originalEvent.storageArea === localStorage && e.originalEvent.newValue) { + // make sure it isn't this tab that is sending the logout signal (only necessary for IE11) + if (BrowserStore.isSignallingLogin(e.originalEvent.newValue)) { + return; + } + + console.log('detected login from a different tab'); //eslint-disable-line no-console + location.reload(); + } + }); + // Fastclick FastClick.attach(document.body); } diff --git a/webapp/components/search_bar.jsx b/webapp/components/search_bar.jsx index a7e9bfcac..c5fcd4697 100644 --- a/webapp/components/search_bar.jsx +++ b/webapp/components/search_bar.jsx @@ -2,9 +2,6 @@ // See License.txt for license information. import $ from 'jquery'; -import ReactDOM from 'react-dom'; -import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import SearchStore from 'stores/search_store.jsx'; import UserStore from 'stores/user_store.jsx'; @@ -15,7 +12,7 @@ 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'; -import {loadProfilesForPosts, getFlaggedPosts} from 'actions/post_actions.jsx'; +import {getFlaggedPosts, performSearch} from 'actions/post_actions.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -119,26 +116,18 @@ export default class SearchBar extends React.Component { if (terms.length) { this.setState({isSearching: true}); - Client.search( + performSearch( terms, isMentionSearch, - (data) => { + () => { this.setState({isSearching: false}); - if (Utils.isMobile()) { - ReactDOM.findDOMNode(this.refs.search).value = ''; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_SEARCH, - results: data, - is_mention_search: isMentionSearch - }); - loadProfilesForPosts(data.posts); + if (Utils.isMobile() && this.search) { + this.search.value = ''; + } }, - (err) => { + () => { this.setState({isSearching: false}); - AsyncClient.dispatchError(err, 'search'); } ); } @@ -147,7 +136,7 @@ export default class SearchBar extends React.Component { handleSubmit(e) { e.preventDefault(); this.performSearch(this.state.searchTerm.trim()); - $(ReactDOM.findDOMNode(this.refs.search)).find('input').blur(); + $(this.search).find('input').blur(); this.clearFocus(); } @@ -276,7 +265,9 @@ export default class SearchBar extends React.Component { > <span className='fa fa-search sidebar__search-icon'/> <SuggestionBox - ref='search' + ref={(search) => { + this.search = search; + }} className='form-control search-bar' placeholder={Utils.localizeMessage('search_bar.search', 'Search')} value={this.state.searchTerm} diff --git a/webapp/components/search_results.jsx b/webapp/components/search_results.jsx index a0245b7e4..86d1bac1d 100644 --- a/webapp/components/search_results.jsx +++ b/webapp/components/search_results.jsx @@ -124,6 +124,12 @@ export default class SearchResults extends React.Component { window.removeEventListener('resize', this.handleResize); } + componentDidUpdate(prevProps, prevState) { + if (this.state.searchTerm !== prevState.searchTerm) { + this.resize(); + } + } + handleResize() { this.setState({ windowWidth: Utils.windowWidth(), diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index 76681959e..be62653c0 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -62,7 +62,7 @@ export default class SearchResultsItem extends React.Component { render() { let channelName = null; const channel = this.props.channel; - const timestamp = UserStore.getCurrentUser().update_at; + const timestamp = UserStore.getCurrentUser().last_picture_update; const user = this.props.user || {}; const post = this.props.post; const flagIcon = Constants.FLAG_ICON_SVG; @@ -285,7 +285,7 @@ export default class SearchResultsItem extends React.Component { </li> {rhsControls} </ul> - <div className='search-item-snippet'> + <div className='search-item-snippet post__body'> {message} </div> </div> diff --git a/webapp/components/setting_item_max.jsx b/webapp/components/setting_item_max.jsx index 904e6c8d1..5971ce584 100644 --- a/webapp/components/setting_item_max.jsx +++ b/webapp/components/setting_item_max.jsx @@ -49,7 +49,7 @@ export default class SettingItemMax extends React.Component { submit = ( <input type='submit' - className='btn btn-sm btn-primary' + className='btn btn-sm btn-primary pull-right' href='#' onClick={this.props.submit} value={Utils.localizeMessage('setting_item_max.save', 'Save')} @@ -88,7 +88,7 @@ export default class SettingItemMax extends React.Component { {clientError} {submit} <a - className='btn btn-sm theme' + className='btn btn-sm pull-right' href='#' onClick={this.props.updateSection} > diff --git a/webapp/components/setting_picture.jsx b/webapp/components/setting_picture.jsx index b74ee8eb7..d1ff60c6a 100644 --- a/webapp/components/setting_picture.jsx +++ b/webapp/components/setting_picture.jsx @@ -73,7 +73,7 @@ export default class SettingPicture extends React.Component { /> ); } else { - var confirmButtonClass = 'btn btn-sm'; + var confirmButtonClass = 'btn btn-sm pull-right'; if (this.props.submitActive) { confirmButtonClass += ' btn-primary'; } else { @@ -132,7 +132,7 @@ export default class SettingPicture extends React.Component { </span> {confirmButton} <a - className='btn btn-sm theme' + className='btn btn-sm theme pull-right' href='#' onClick={self.props.updateSection} > diff --git a/webapp/components/should_verify_email.jsx b/webapp/components/should_verify_email.jsx index 5ac67e383..61edf9422 100644 --- a/webapp/components/should_verify_email.jsx +++ b/webapp/components/should_verify_email.jsx @@ -2,11 +2,12 @@ // See License.txt for license information. import {FormattedMessage} from 'react-intl'; -import Client from 'client/web_client.jsx'; import React from 'react'; import {Link} from 'react-router/es6'; +import {resendVerification} from 'actions/user_actions.jsx'; + export default class ShouldVerifyEmail extends React.Component { constructor(props) { super(props); @@ -22,7 +23,7 @@ export default class ShouldVerifyEmail extends React.Component { this.setState({resendStatus: 'sending'}); - Client.resendVerification( + resendVerification( email, () => { this.setState({resendStatus: 'success'}); diff --git a/webapp/components/sidebar_header.jsx b/webapp/components/sidebar_header.jsx index a5fbd2659..9bc4a5639 100644 --- a/webapp/components/sidebar_header.jsx +++ b/webapp/components/sidebar_header.jsx @@ -10,7 +10,7 @@ import * as Utils from 'utils/utils.jsx'; import SidebarHeaderDropdown from './sidebar_header_dropdown.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; -import {Preferences, TutorialSteps} from 'utils/constants.jsx'; +import {Preferences, TutorialSteps, Constants} from 'utils/constants.jsx'; import {createMenuTip} from 'components/tutorial/tutorial_tip.jsx'; export default class SidebarHeader extends React.Component { @@ -59,7 +59,7 @@ export default class SidebarHeader extends React.Component { profilePicture = ( <img className='user__picture' - src={Client.getUsersRoute() + '/' + me.id + '/image?time=' + me.update_at} + src={Client.getUsersRoute() + '/' + me.id + '/image?time=' + me.last_picture_update} /> ); } @@ -78,7 +78,7 @@ export default class SidebarHeader extends React.Component { teamNameWithToolTip = ( <OverlayTrigger trigger={['hover', 'focus']} - delayShow={1000} + delayShow={Constants.OVERLAY_TIME_DELAY} placement='bottom' overlay={<Tooltip id='team-name__tooltip'>{this.props.teamDescription}</Tooltip>} ref='descriptionOverlay' @@ -91,16 +91,13 @@ export default class SidebarHeader extends React.Component { return ( <div className='team__header theme'> {tutorialTip} - <a - href='#' - onClick={this.toggleDropdown} - > + <div> {profilePicture} <div className='header__info'> <div className='user__name'>{'@' + me.username}</div> {teamNameWithToolTip} </div> - </a> + </div> <SidebarHeaderDropdown ref='dropdown' teamType={this.props.teamType} diff --git a/webapp/components/sidebar_header_dropdown.jsx b/webapp/components/sidebar_header_dropdown.jsx index 826d9a342..86432e3ab 100644 --- a/webapp/components/sidebar_header_dropdown.jsx +++ b/webapp/components/sidebar_header_dropdown.jsx @@ -351,7 +351,10 @@ export default class SidebarHeaderDropdown extends React.Component { if (moreTeams) { teams.push( <li key='joinTeam_li'> - <Link to='/select_team'> + <Link + onClick={this.handleClick} + to='/select_team' + > <FormattedMessage id='navbar_dropdown.join' defaultMessage='Join Another Team' diff --git a/webapp/components/signup/components/signup_email.jsx b/webapp/components/signup/components/signup_email.jsx index aa3493c96..9ed10b94c 100644 --- a/webapp/components/signup/components/signup_email.jsx +++ b/webapp/components/signup/components/signup_email.jsx @@ -5,11 +5,10 @@ import LoadingScreen from 'components/loading_screen.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {track} from 'actions/analytics_actions.jsx'; - -import BrowserStore from 'stores/browser_store.jsx'; +import {getInviteInfo} from 'actions/team_actions.jsx'; +import {loginById, createUserWithInvite} from 'actions/user_actions.jsx'; import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; import Constants from 'utils/constants.jsx'; import React from 'react'; @@ -58,7 +57,7 @@ export default class SignupEmail extends React.Component { loading = false; } else if (inviteId && inviteId.length > 0) { loading = true; - Client.getInviteInfo( + getInviteInfo( inviteId, (inviteData) => { if (!inviteData) { @@ -118,26 +117,12 @@ export default class SignupEmail extends React.Component { handleSignupSuccess(user, data) { track('signup', 'signup_user_02_complete'); - Client.loginById( + loginById( data.id, user.password, '', - () => { - if (this.state.hash > 0) { - BrowserStore.setGlobalItem(this.state.hash, JSON.stringify({usedBefore: true})); - } - - GlobalActions.emitInitialLoad( - () => { - const query = this.props.location.query; - if (query.redirect_to) { - browserHistory.push(query.redirect_to); - } else { - GlobalActions.redirectUserToDefaultTeam(); - } - } - ); - }, + this.state.hash, + null, (err) => { if (err.id === 'api.user.login.not_verified.app_error') { browserHistory.push('/should_verify_email?email=' + encodeURIComponent(user.email) + '&teamname=' + encodeURIComponent(this.state.teamName)); @@ -241,7 +226,7 @@ export default class SignupEmail extends React.Component { allow_marketing: true }; - Client.createUserWithInvite(user, + createUserWithInvite(user, this.state.data, this.state.hash, this.state.inviteId, diff --git a/webapp/components/signup/components/signup_ldap.jsx b/webapp/components/signup/components/signup_ldap.jsx index d80b27159..4c9afc8d6 100644 --- a/webapp/components/signup/components/signup_ldap.jsx +++ b/webapp/components/signup/components/signup_ldap.jsx @@ -5,9 +5,10 @@ import FormError from 'components/form_error.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {track} from 'actions/analytics_actions.jsx'; +import {addUserToTeamFromInvite} from 'actions/team_actions.jsx'; +import {webLoginByLdap} from 'actions/user_actions.jsx'; import * as Utils from 'utils/utils.jsx'; -import Client from 'client/web_client.jsx'; import React from 'react'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; @@ -55,7 +56,7 @@ export default class SignupLdap extends React.Component { this.setState({ldapError: ''}); - Client.webLoginByLdap( + webLoginByLdap( this.state.ldapId, this.state.ldapPassword, null, @@ -69,11 +70,15 @@ export default class SignupLdap extends React.Component { } handleLdapSignupSuccess() { - if (this.props.location.query.id || this.props.location.query.h) { - Client.addUserToTeamFromInvite( - this.props.location.query.d, - this.props.location.query.h, - this.props.location.query.id, + const hash = this.props.location.query.h; + const data = this.props.location.query.d; + const inviteId = this.props.location.query.id; + + if (inviteId || hash) { + addUserToTeamFromInvite( + data, + hash, + inviteId, () => { this.finishSignup(); }, diff --git a/webapp/components/signup/signup_controller.jsx b/webapp/components/signup/signup_controller.jsx index 9bf5936be..737431926 100644 --- a/webapp/components/signup/signup_controller.jsx +++ b/webapp/components/signup/signup_controller.jsx @@ -12,6 +12,7 @@ import BrowserStore from 'stores/browser_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import Client from 'client/web_client.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import {addUserToTeamFromInvite, getInviteInfo} from 'actions/team_actions.jsx'; import logoImage from 'images/logo.png'; import ErrorBar from 'components/error_bar.jsx'; @@ -68,7 +69,7 @@ export default class SignupController extends React.Component { const userLoggedIn = UserStore.getCurrentUser() != null; if ((inviteId || hash) && userLoggedIn) { - Client.addUserToTeamFromInvite( + addUserToTeamFromInvite( data, hash, inviteId, @@ -79,11 +80,16 @@ export default class SignupController extends React.Component { } ); }, - (e) => { + () => { this.setState({ // eslint-disable-line react/no-did-mount-set-state noOpenServerError: true, loading: false, - serverError: e.message + serverError: ( + <FormattedMessage + id='signup_user_completed.invalid_invite' + defaultMessage='The invite link was invalid. Please speak with your Administrator to receive an invitation.' + /> + ) }); } ); @@ -92,7 +98,7 @@ export default class SignupController extends React.Component { } if (inviteId) { - Client.getInviteInfo( + getInviteInfo( inviteId, (inviteData) => { if (!inviteData) { diff --git a/webapp/components/suggestion/at_mention_provider.jsx b/webapp/components/suggestion/at_mention_provider.jsx index 9263c6e50..5f79e08ae 100644 --- a/webapp/components/suggestion/at_mention_provider.jsx +++ b/webapp/components/suggestion/at_mention_provider.jsx @@ -6,6 +6,7 @@ import Provider from './provider.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import SuggestionStore from 'stores/suggestion_store.jsx'; import {autocompleteUsersInChannel} from 'actions/user_actions.jsx'; @@ -70,7 +71,7 @@ class AtMentionSuggestion extends Suggestion { icon = ( <img className='mention__image' - src={Client.getUsersRoute() + '/' + user.id + '/image?time=' + user.update_at} + src={Client.getUsersRoute() + '/' + user.id + '/image?time=' + user.last_picture_update} /> ); } @@ -161,6 +162,8 @@ export default class AtMentionProvider extends Provider { }); } ); + } else { + SuggestionStore.clearSuggestions(suggestionId); } } } diff --git a/webapp/components/suggestion/channel_mention_provider.jsx b/webapp/components/suggestion/channel_mention_provider.jsx index 63e6944ac..f1d6d9e76 100644 --- a/webapp/components/suggestion/channel_mention_provider.jsx +++ b/webapp/components/suggestion/channel_mention_provider.jsx @@ -51,61 +51,83 @@ class ChannelMentionSuggestion extends Suggestion { } export default class ChannelMentionProvider extends Provider { + constructor() { + super(); + + this.lastCompletedWord = ''; + } + handlePretextChanged(suggestionId, pretext) { - const captured = (/(^|\s)(~([^~]*))$/i).exec(pretext.toLowerCase()); - if (captured) { - const prefix = captured[3]; + const captured = (/(^|\s)(~([^~\r\n]*))$/i).exec(pretext.toLowerCase()); - this.startNewRequest(prefix); + if (!captured) { + // Not a channel mention + return; + } - autocompleteChannels( - prefix, - (data) => { - if (this.shouldCancelDispatch(prefix)) { - return; - } + if (this.lastCompletedWord && captured[0].startsWith(this.lastCompletedWord)) { + // It appears we're still matching a channel handle that we already completed + return; + } + + // Clear the last completed word since we've started to match new text + this.lastCompletedWord = ''; + + const prefix = captured[3]; + + this.startNewRequest(prefix); + + autocompleteChannels( + prefix, + (data) => { + if (this.shouldCancelDispatch(prefix)) { + return; + } + + const channels = data; - const channels = data; - - // Wrap channels in an outer object to avoid overwriting the 'type' property. - const wrappedChannels = []; - const wrappedMoreChannels = []; - const moreChannels = []; - channels.forEach((item) => { - if (ChannelStore.get(item.id)) { - wrappedChannels.push({ - type: Constants.MENTION_CHANNELS, - channel: item - }); - return; - } - - wrappedMoreChannels.push({ - type: Constants.MENTION_MORE_CHANNELS, + // Wrap channels in an outer object to avoid overwriting the 'type' property. + const wrappedChannels = []; + const wrappedMoreChannels = []; + const moreChannels = []; + channels.forEach((item) => { + if (ChannelStore.get(item.id)) { + wrappedChannels.push({ + type: Constants.MENTION_CHANNELS, channel: item }); + return; + } - moreChannels.push(item); + wrappedMoreChannels.push({ + type: Constants.MENTION_MORE_CHANNELS, + channel: item }); - const wrapped = wrappedChannels.concat(wrappedMoreChannels); - const mentions = wrapped.map((item) => '~' + item.channel.name); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_MORE_CHANNELS, - channels: moreChannels - }); + moreChannels.push(item); + }); + + const wrapped = wrappedChannels.concat(wrappedMoreChannels); + const mentions = wrapped.map((item) => '~' + item.channel.name); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MORE_CHANNELS, + channels: moreChannels + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: captured[2], + terms: mentions, + items: wrapped, + component: ChannelMentionSuggestion + }); + } + ); + } - AppDispatcher.handleServerAction({ - type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, - id: suggestionId, - matchedPretext: captured[2], - terms: mentions, - items: wrapped, - component: ChannelMentionSuggestion - }); - } - ); - } + handleCompleteWord(term) { + this.lastCompletedWord = term; } } diff --git a/webapp/components/suggestion/emoticon_provider.jsx b/webapp/components/suggestion/emoticon_provider.jsx index 6bb0aee3b..6a4332e2f 100644 --- a/webapp/components/suggestion/emoticon_provider.jsx +++ b/webapp/components/suggestion/emoticon_provider.jsx @@ -14,7 +14,7 @@ const MIN_EMOTICON_LENGTH = 2; class EmoticonSuggestion extends Suggestion { render() { const text = this.props.term; - const emoticon = this.props.item; + const emoji = this.props.item.emoji; let className = 'emoticon-suggestion'; if (this.props.isSelection) { @@ -30,7 +30,7 @@ class EmoticonSuggestion extends Suggestion { <img alt={text} className='emoticon-suggestion__image' - src={EmojiStore.getEmojiImageUrl(emoticon)} + src={EmojiStore.getEmojiImageUrl(emoji)} title={text} /> </div> @@ -73,15 +73,24 @@ export default class EmoticonProvider { // check for named emoji for (const [name, emoji] of EmojiStore.getEmojis()) { - if (name.indexOf(partialName) !== -1) { - matched.push(emoji); + if (emoji.aliases) { + // This is a system emoji so it may have multiple names + for (const alias of emoji.aliases) { + if (alias.indexOf(partialName) !== -1) { + matched.push({name: alias, emoji}); + break; + } + } + } else if (name.indexOf(partialName) !== -1) { + // This is a custom emoji so it only has one name + matched.push({name, emoji}); } } // sort the emoticons so that emoticons starting with the entered text come first matched.sort((a, b) => { - const aName = a.name || a.aliases[0]; - const bName = b.name || b.aliases[0]; + const aName = a.name; + const bName = b.name; const aPrefix = aName.startsWith(partialName); const bPrefix = bName.startsWith(partialName); @@ -95,7 +104,7 @@ export default class EmoticonProvider { return 1; }); - const terms = matched.map((emoticon) => ':' + (emoticon.name || emoticon.aliases[0]) + ':'); + const terms = matched.map((item) => ':' + item.name + ':'); SuggestionStore.clearSuggestions(suggestionId); if (terms.length > 0) { diff --git a/webapp/components/suggestion/search_user_provider.jsx b/webapp/components/suggestion/search_user_provider.jsx index bff59ace8..70808ca26 100644 --- a/webapp/components/suggestion/search_user_provider.jsx +++ b/webapp/components/suggestion/search_user_provider.jsx @@ -41,7 +41,7 @@ class SearchUserSuggestion extends Suggestion { <i className='fa fa fa-plus-square'/> <img className='profile-img rounded' - src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.update_at} + src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.last_picture_update} /> <div className='mention--align'> <span> diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx index e9f7c3699..29b9b2d8b 100644 --- a/webapp/components/suggestion/suggestion_box.jsx +++ b/webapp/components/suggestion/suggestion_box.jsx @@ -153,6 +153,12 @@ export default class SuggestionBox extends React.Component { window.requestAnimationFrame(() => { Utils.setCaretPosition(textbox, prefix.length + term.length + 1); }); + + for (const provider of this.props.providers) { + if (provider.handleCompleteWord) { + provider.handleCompleteWord(term, matchedPretext); + } + } } handleKeyDown(e) { diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx index 301974b9a..0bc30a79f 100644 --- a/webapp/components/suggestion/switch_channel_provider.jsx +++ b/webapp/components/suggestion/switch_channel_provider.jsx @@ -35,7 +35,7 @@ class SwitchChannelSuggestion extends Suggestion { <div className='pull-left'> <img className='mention__image' - src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.update_at} + src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.last_picture_update} /> </div> ); diff --git a/webapp/components/team_general_tab.jsx b/webapp/components/team_general_tab.jsx index 955a71ac5..0100cad64 100644 --- a/webapp/components/team_general_tab.jsx +++ b/webapp/components/team_general_tab.jsx @@ -8,60 +8,9 @@ import SettingItemMax from './setting_item_max.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; -import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import {updateTeam} from 'actions/team_actions.jsx'; -const holders = defineMessages({ - dirDisabled: { - id: 'general_tab.dirDisabled', - defaultMessage: 'Team Directory has been disabled. Please ask a System Admin to enable the Team Directory in the System Console team settings.' - }, - required: { - id: 'general_tab.required', - defaultMessage: 'This field is required' - }, - chooseName: { - id: 'general_tab.chooseName', - defaultMessage: 'Please choose a new name for your team' - }, - includeDirTitle: { - id: 'general_tab.includeDirTitle', - defaultMessage: 'Include this team in the Team Directory' - }, - yes: { - id: 'general_tab.yes', - defaultMessage: 'Yes' - }, - no: { - id: 'general_tab.no', - defaultMessage: 'No' - }, - dirOff: { - id: 'general_tab.dirOff', - defaultMessage: 'Team directory is turned off for this system.' - }, - openInviteTitle: { - id: 'general_tab.openInviteTitle', - defaultMessage: 'Allow any user with an account on this server to join this team' - }, - codeTitle: { - id: 'general_tab.codeTitle', - defaultMessage: 'Invite Code' - }, - codeDesc: { - id: 'general_tab.codeDesc', - defaultMessage: "Click 'Edit' to regenerate Invite Code." - }, - teamNameInfo: { - id: 'general_tab.teamNameInfo', - defaultMessage: 'Set the name of the team as it appears on your sign-in screen and at the top of the left-hand sidebar.' - }, - teamDescriptionInfo: { - id: 'general_tab.teamDescriptionInfo', - defaultMessage: 'Team description provides additional information to help users select the right team. Maximum of 50 characters.' - } -}); - import React from 'react'; class GeneralTab extends React.Component { @@ -156,13 +105,12 @@ class GeneralTab extends React.Component { var state = {serverError: '', clientError: ''}; let valid = true; - const {formatMessage} = this.props.intl; const name = this.state.name.trim(); if (!name) { - state.clientError = formatMessage(holders.required); + state.clientError = Utils.localizeMessage('general_tab.required', 'This field is required'); valid = false; } else if (name === this.props.team.display_name) { - state.clientError = formatMessage(holders.chooseName); + state.clientError = Utils.localizeMessage('general_tab.chooseName', 'Please choose a new name for your team'); valid = false; } else { state.clientError = ''; @@ -197,7 +145,7 @@ class GeneralTab extends React.Component { if (inviteId) { state.clientError = ''; } else { - state.clientError = this.props.intl.fromatMessage(holders.required); + state.clientError = Utils.localizeMessage('general_tab.required', 'This field is required'); valid = false; } @@ -230,10 +178,9 @@ class GeneralTab extends React.Component { var state = {serverError: '', clientError: ''}; let valid = true; - const {formatMessage} = this.props.intl; const description = this.state.description.trim(); if (description === this.props.team.description) { - state.clientError = formatMessage(holders.chooseName); + state.clientError = Utils.localizeMessage('general_tab.chooseDescription', 'Please choose a new description for your team'); valid = false; } else { state.clientError = ''; @@ -324,8 +271,6 @@ class GeneralTab extends React.Component { serverError = this.state.serverError; } - const {formatMessage} = this.props.intl; - let openInviteSection; if (this.props.activeSection === 'open_invite') { const inputs = [ @@ -372,7 +317,7 @@ class GeneralTab extends React.Component { openInviteSection = ( <SettingItemMax - title={formatMessage(holders.openInviteTitle)} + title={Utils.localizeMessage('general_tab.openInviteTitle', 'Allow any user with an account on this server to join this team')} inputs={inputs} submit={this.handleOpenInviteSubmit} server_error={serverError} @@ -382,14 +327,14 @@ class GeneralTab extends React.Component { } else { let describe = ''; if (this.state.allow_open_invite === true) { - describe = formatMessage(holders.yes); + describe = Utils.localizeMessage('general_tab.yes', 'Yes'); } else { - describe = formatMessage(holders.no); + describe = Utils.localizeMessage('general_tab.no', 'No'); } openInviteSection = ( <SettingItemMin - title={formatMessage(holders.openInviteTitle)} + title={Utils.localizeMessage('general_tab.openInviteTitle', 'Allow any user with an account on this server to join this team')} describe={describe} updateSection={this.onUpdateOpenInviteSection} /> @@ -427,9 +372,19 @@ class GeneralTab extends React.Component { </div> </div> <div className='setting-list__hint'> - <FormattedHTMLMessage + <FormattedMessage id='general_tab.codeLongDesc' - defaultMessage='The Invite Code is used as part of the URL in the team invitation link created by <strong>Get Team Invite Link</strong> in the main menu. Regenerating creates a new team invitation link and invalidates the previous link.' + defaultMessage='The Invite Code is used as part of the URL in the team invitation link created by {getTeamInviteLink} in the main menu. Regenerating creates a new team invitation link and invalidates the previous link.' + values={{ + getTeamInviteLink: ( + <strong> + <FormattedMessage + id='general_tab.getTeamInviteLink' + defaultMessage='Get Team Invite Link' + /> + </strong> + ) + }} /> </div> </div> @@ -437,7 +392,7 @@ class GeneralTab extends React.Component { inviteSection = ( <SettingItemMax - title={formatMessage(holders.codeTitle)} + title={Utils.localizeMessage('general_tab.codeTitle', 'Invite Code')} inputs={inputs} submit={this.handleInviteIdSubmit} server_error={serverError} @@ -448,8 +403,8 @@ class GeneralTab extends React.Component { } else { inviteSection = ( <SettingItemMin - title={formatMessage(holders.codeTitle)} - describe={formatMessage(holders.codeDesc)} + title={Utils.localizeMessage('general_tab.codeTitle', 'Invite Code')} + describe={Utils.localizeMessage('general_tab.codeDesc', "Click 'Edit' to regenerate Invite Code.")} updateSection={this.onUpdateInviteIdSection} /> ); @@ -488,11 +443,11 @@ class GeneralTab extends React.Component { </div> ); - const nameExtraInfo = <span>{formatMessage(holders.teamNameInfo)}</span>; + const nameExtraInfo = <span>{Utils.localizeMessage('general_tab.teamNameInfo', 'Set the name of the team as it appears on your sign-in screen and at the top of the left-hand sidebar.')}</span>; nameSection = ( <SettingItemMax - title={formatMessage({id: 'general_tab.teamName'})} + title={Utils.localizeMessage('general_tab.teamName', 'Team Name')} inputs={inputs} submit={this.handleNameSubmit} server_error={serverError} @@ -506,7 +461,7 @@ class GeneralTab extends React.Component { nameSection = ( <SettingItemMin - title={formatMessage({id: 'general_tab.teamName'})} + title={Utils.localizeMessage('general_tab.teamName', 'Team Name')} describe={describe} updateSection={this.onUpdateNameSection} /> @@ -546,11 +501,11 @@ class GeneralTab extends React.Component { </div> ); - const descriptionExtraInfo = <span>{formatMessage(holders.teamDescriptionInfo)}</span>; + const descriptionExtraInfo = <span>{Utils.localizeMessage('general_tab.teamDescriptionInfo', 'Team description provides additional information to help users select the right team. Maximum of 50 characters.')}</span>; descriptionSection = ( <SettingItemMax - title={formatMessage({id: 'general_tab.teamDescription'})} + title={Utils.localizeMessage('general_tab.teamDescription', 'Team Description')} inputs={inputs} submit={this.handleDescriptionSubmit} server_error={serverError} @@ -574,7 +529,7 @@ class GeneralTab extends React.Component { descriptionSection = ( <SettingItemMin - title={formatMessage({id: 'general_tab.teamDescription'})} + title={Utils.localizeMessage('general_tab.teamDescription', 'Team Description')} describe={describemsg} updateSection={this.onUpdateDescriptionSection} /> @@ -633,10 +588,9 @@ class GeneralTab extends React.Component { } GeneralTab.propTypes = { - intl: intlShape.isRequired, updateSection: React.PropTypes.func.isRequired, team: React.PropTypes.object.isRequired, activeSection: React.PropTypes.string.isRequired }; -export default injectIntl(GeneralTab); +export default GeneralTab; diff --git a/webapp/components/user_list_row.jsx b/webapp/components/user_list_row.jsx index ff381a30b..3a13ccb66 100644 --- a/webapp/components/user_list_row.jsx +++ b/webapp/components/user_list_row.jsx @@ -64,7 +64,7 @@ export default function UserListRow({user, extraInfo, actions, actionProps, acti className='more-modal__row' > <ProfilePicture - src={`${Client.getUsersRoute()}/${user.id}/image?time=${user.update_at}`} + src={`${Client.getUsersRoute()}/${user.id}/image?time=${user.last_picture_update}`} status={status} width='32' height='32' diff --git a/webapp/components/user_profile.jsx b/webapp/components/user_profile.jsx index d0267c0d8..d9bd5c378 100644 --- a/webapp/components/user_profile.jsx +++ b/webapp/components/user_profile.jsx @@ -56,7 +56,7 @@ export default class UserProfile extends React.Component { let profileImg = ''; if (this.props.user) { name = Utils.displayUsername(this.props.user.id); - profileImg = Client.getUsersRoute() + '/' + this.props.user.id + '/image?time=' + this.props.user.update_at; + profileImg = Client.getUsersRoute() + '/' + this.props.user.id + '/image?time=' + this.props.user.last_picture_update; } if (this.props.overwriteName) { diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx index 70306d871..dc6f4ac0c 100644 --- a/webapp/components/user_settings/user_settings_advanced.jsx +++ b/webapp/components/user_settings/user_settings_advanced.jsx @@ -332,7 +332,7 @@ export default class AdvancedSettingsDisplay extends React.Component { return ( <FormattedMessage id='user.settings.advance.embed_preview' - defaultMessage='Show experimental previews of link content, when available' + defaultMessage='For the first web link in a message, display a preview of website content below the message, if available' /> ); case 'WEBRTC_PREVIEW': diff --git a/webapp/components/user_settings/user_settings_display.jsx b/webapp/components/user_settings/user_settings_display.jsx index 9ffc4f721..f51128b6f 100644 --- a/webapp/components/user_settings/user_settings_display.jsx +++ b/webapp/components/user_settings/user_settings_display.jsx @@ -191,7 +191,7 @@ export default class UserSettingsDisplay extends React.Component { <br/> <FormattedMessage id='user.settings.display.collapseDesc' - defaultMessage='Expand links to show a preview of content, when available.' + defaultMessage='Set whether previews of image links show as expanded or collapsed by default. This setting can also be controlled using the slash commands /expand and /collapse.' /> </div> </div> @@ -202,7 +202,7 @@ export default class UserSettingsDisplay extends React.Component { title={ <FormattedMessage id='user.settings.display.collapseDisplay' - defaultMessage='Link previews' + defaultMessage='Default appearance of image link previews' /> } inputs={inputs} @@ -218,14 +218,14 @@ export default class UserSettingsDisplay extends React.Component { describe = ( <FormattedMessage id='user.settings.display.collapseOn' - defaultMessage='On' + defaultMessage='Expanded' /> ); } else { describe = ( <FormattedMessage id='user.settings.display.collapseOff' - defaultMessage='Off' + defaultMessage='Collapsed' /> ); } @@ -239,7 +239,7 @@ export default class UserSettingsDisplay extends React.Component { title={ <FormattedMessage id='user.settings.display.collapseDisplay' - defaultMessage='Link previews' + defaultMessage='Default appearance of image link previews' /> } describe={describe} diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx index 06fe31a9e..d9551dccc 100644 --- a/webapp/components/user_settings/user_settings_general.jsx +++ b/webapp/components/user_settings/user_settings_general.jsx @@ -15,7 +15,7 @@ import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage, FormattedDate} from 'react-intl'; -import {updateUser} from 'actions/user_actions.jsx'; +import {updateUser, uploadProfileImage} from 'actions/user_actions.jsx'; const holders = defineMessages({ usernameReserved: { @@ -241,11 +241,11 @@ class UserSettingsGeneralTab extends React.Component { this.setState({loadingPicture: true}); - Client.uploadProfileImage(picture, + uploadProfileImage( + picture, () => { this.updateSection(''); this.submitActive = false; - AsyncClient.getMe(); }, (err) => { var state = this.setupInitialState(this.props); diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx index 2ee33c092..672f8d6b7 100644 --- a/webapp/components/user_settings/user_settings_notifications.jsx +++ b/webapp/components/user_settings/user_settings_notifications.jsx @@ -8,10 +8,9 @@ import DesktopNotificationSettings from './desktop_notification_settings.jsx'; import UserStore from 'stores/user_store.jsx'; -import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; +import {updateUserNotifyProps} from 'actions/user_actions.jsx'; import EmailNotificationSetting from './email_notification_setting.jsx'; import {FormattedMessage} from 'react-intl'; @@ -143,10 +142,10 @@ export default class NotificationsTab extends React.Component { data.first_name = this.state.firstNameKey.toString(); data.channel = this.state.channelKey.toString(); - Client.updateUserNotifyProps(data, + updateUserNotifyProps( + data, () => { this.props.updateSection(''); - AsyncClient.getMe(); $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); }, (err) => { diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx index 3484b8183..210e455b7 100644 --- a/webapp/components/user_settings/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -9,11 +9,12 @@ import ToggleModalButton from '../toggle_modal_button.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; -import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; +import {updatePassword, getAuthorizedApps, deactivateMfa, deauthorizeOAuthApp} from 'actions/user_actions.jsx'; + import $ from 'jquery'; import React from 'react'; import {FormattedMessage, FormattedTime, FormattedDate} from 'react-intl'; @@ -27,7 +28,7 @@ export default class SecurityTab extends React.Component { this.submitPassword = this.submitPassword.bind(this); this.setupMfa = this.setupMfa.bind(this); - this.deactivateMfa = this.deactivateMfa.bind(this); + this.removeMfa = this.removeMfa.bind(this); this.updateCurrentPassword = this.updateCurrentPassword.bind(this); this.updateNewPassword = this.updateNewPassword.bind(this); this.updateConfirmPassword = this.updateConfirmPassword.bind(this); @@ -53,7 +54,7 @@ export default class SecurityTab extends React.Component { componentDidMount() { if (global.mm_config.EnableOAuthServiceProvider === 'true') { - Client.getAuthorizedApps( + getAuthorizedApps( (authorizedApps) => { this.setState({authorizedApps, serverError: null}); //eslint-disable-line react/no-did-mount-set-state }, @@ -91,7 +92,7 @@ export default class SecurityTab extends React.Component { return; } - Client.updatePassword( + updatePassword( user.id, currentPassword, newPassword, @@ -118,10 +119,8 @@ export default class SecurityTab extends React.Component { browserHistory.push('/mfa/setup'); } - deactivateMfa() { - Client.updateMfa( - '', - false, + removeMfa() { + deactivateMfa( () => { if (global.window.mm_license.MFA === 'true' && global.window.mm_config.EnableMultifactorAuthentication === 'true' && @@ -131,7 +130,6 @@ export default class SecurityTab extends React.Component { } this.props.updateSection(''); - AsyncClient.getMe(); this.setState(this.getDefaultState()); }, (err) => { @@ -161,7 +159,7 @@ export default class SecurityTab extends React.Component { deauthorizeApp(e) { e.preventDefault(); const appId = e.currentTarget.getAttribute('data-app'); - Client.deauthorizeOAuthApp( + deauthorizeOAuthApp( appId, () => { const authorizedApps = this.state.authorizedApps.filter((app) => { @@ -221,7 +219,7 @@ export default class SecurityTab extends React.Component { <a className='btn btn-primary' href='#' - onClick={this.deactivateMfa} + onClick={this.removeMfa} > {mfaButtonText} </a> @@ -425,6 +423,34 @@ export default class SecurityTab extends React.Component { </div> </div> ); + } else if (this.props.user.auth_service === Constants.GOOGLE_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.security.passwordGoogleCantUpdate' + defaultMessage='Login occurs through Google Apps. Password cannot be updated.' + /> + </div> + </div> + ); + } else if (this.props.user.auth_service === Constants.OFFICE365_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.security.passwordOffice365CantUpdate' + defaultMessage='Login occurs through Office 365. Password cannot be updated.' + /> + </div> + </div> + ); } updateSectionStatus = function resetSection(e) { @@ -502,6 +528,20 @@ export default class SecurityTab extends React.Component { defaultMessage='Login done through SAML' /> ); + } else if (this.props.user.auth_service === Constants.GOOGLE_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.loginGoogle' + defaultMessage='Login done through Google Apps' + /> + ); + } else if (this.props.user.auth_service === Constants.OFFICE365_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.loginOffice365' + defaultMessage='Login done through Office 365' + /> + ); } updateSectionStatus = function updateSection() { diff --git a/webapp/components/webrtc/components/webrtc_notification.jsx b/webapp/components/webrtc/components/webrtc_notification.jsx index 5456d6cb8..f69e731f8 100644 --- a/webapp/components/webrtc/components/webrtc_notification.jsx +++ b/webapp/components/webrtc/components/webrtc_notification.jsx @@ -197,7 +197,7 @@ export default class WebrtcNotification extends React.Component { const user = this.state.userCalling; if (user) { const username = Utils.displayUsername(user.id); - const profileImgSrc = Client.getUsersRoute() + '/' + user.id + '/image?time=' + (user.update_at || new Date().getTime()); + const profileImgSrc = Client.getUsersRoute() + '/' + user.id + '/image?time=' + (user.last_picture_update || new Date().getTime()); const profileImg = ( <img className='user-popover__image' diff --git a/webapp/components/webrtc/webrtc_controller.jsx b/webapp/components/webrtc/webrtc_controller.jsx index 94e5b3475..b8d3d4db6 100644 --- a/webapp/components/webrtc/webrtc_controller.jsx +++ b/webapp/components/webrtc/webrtc_controller.jsx @@ -81,14 +81,14 @@ export default class WebrtcController extends React.Component { const currentUser = UserStore.getCurrentUser(); const remoteUser = UserStore.getProfile(props.userId); - const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.update_at; + const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.last_picture_update; this.state = { windowWidth: Utils.windowWidth(), windowHeight: Utils.windowHeight(), channelId: ChannelStore.getCurrentId(), currentUser, - currentUserImage: Client.getUsersRoute() + '/' + currentUser.id + '/image?time=' + currentUser.update_at, + currentUserImage: Client.getUsersRoute() + '/' + currentUser.id + '/image?time=' + currentUser.last_picture_update, remoteUserImage, localMediaLoaded: false, isPaused: false, @@ -130,7 +130,7 @@ export default class WebrtcController extends React.Component { (nextProps.userId !== this.props.userId) || (nextProps.isCaller !== this.props.isCaller)) { const remoteUser = UserStore.getProfile(nextProps.userId); - const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.update_at; + const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.last_picture_update; this.setState({ error: null, remoteUserImage @@ -644,7 +644,7 @@ export default class WebrtcController extends React.Component { } onConnectCall() { - Client.webrtcToken( + WebrtcActions.webrtcToken( (info) => { const connectingMsg = ( <FormattedMessage |