summaryrefslogtreecommitdiffstats
path: root/web/react/components
diff options
context:
space:
mode:
Diffstat (limited to 'web/react/components')
-rw-r--r--web/react/components/access_history_modal.jsx3
-rw-r--r--web/react/components/admin_console/admin_controller.jsx2
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx4
-rw-r--r--web/react/components/admin_console/analytics.jsx37
-rw-r--r--web/react/components/admin_console/audits.jsx5
-rw-r--r--web/react/components/admin_console/email_settings.jsx26
-rw-r--r--web/react/components/admin_console/ldap_settings.jsx2
-rw-r--r--web/react/components/admin_console/license_settings.jsx18
-rw-r--r--web/react/components/admin_console/service_settings.jsx104
-rw-r--r--web/react/components/audio_video_preview.jsx4
-rw-r--r--web/react/components/audit_table.jsx655
-rw-r--r--web/react/components/center_panel.jsx9
-rw-r--r--web/react/components/channel_header.jsx2
-rw-r--r--web/react/components/channel_loader.jsx62
-rw-r--r--web/react/components/create_comment.jsx20
-rw-r--r--web/react/components/create_post.jsx92
-rw-r--r--web/react/components/delete_post_modal.jsx2
-rw-r--r--web/react/components/file_attachment.jsx16
-rw-r--r--web/react/components/file_info_preview.jsx19
-rw-r--r--web/react/components/file_upload.jsx8
-rw-r--r--web/react/components/login_username.jsx4
-rw-r--r--web/react/components/navbar.jsx89
-rw-r--r--web/react/components/post_attachment.jsx22
-rw-r--r--web/react/components/post_body.jsx75
-rw-r--r--web/react/components/post_focus_view.jsx9
-rw-r--r--web/react/components/post_info.jsx57
-rw-r--r--web/react/components/posts_view.jsx27
-rw-r--r--web/react/components/search_results_item.jsx118
-rw-r--r--web/react/components/suggestion/command_provider.jsx3
-rw-r--r--web/react/components/textbox.jsx12
-rw-r--r--web/react/components/time_since.jsx19
-rw-r--r--web/react/components/user_settings/manage_command_hooks.jsx673
-rw-r--r--web/react/components/user_settings/user_settings_display.jsx2
-rw-r--r--web/react/components/user_settings/user_settings_integrations.jsx43
-rw-r--r--web/react/components/user_settings/user_settings_notifications.jsx6
-rw-r--r--web/react/components/view_image.jsx28
-rw-r--r--web/react/components/view_image_popover_bar.jsx23
37 files changed, 1746 insertions, 554 deletions
diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx
index 98b1d7cc1..af4d3fb0f 100644
--- a/web/react/components/access_history_modal.jsx
+++ b/web/react/components/access_history_modal.jsx
@@ -73,7 +73,8 @@ class AccessHistoryModal extends React.Component {
content = (
<AuditTable
audits={this.state.audits}
- moreInfo={this.state.moreInfo}
+ showIp={true}
+ showSession={true}
/>
);
}
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx
index 360ae3ef3..695e2083a 100644
--- a/web/react/components/admin_console/admin_controller.jsx
+++ b/web/react/components/admin_console/admin_controller.jsx
@@ -160,7 +160,7 @@ export default class AdminController extends React.Component {
} else if (this.state.selected === 'ldap_settings') {
tab = <LdapSettingsTab config={this.state.config} />;
} else if (this.state.selected === 'license') {
- tab = <LicenseSettingsTab />;
+ tab = <LicenseSettingsTab config={this.state.config} />;
} else if (this.state.selected === 'team_users') {
if (this.state.teams) {
tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx
index 642bfe9d7..eadd8d412 100644
--- a/web/react/components/admin_console/admin_sidebar.jsx
+++ b/web/react/components/admin_console/admin_sidebar.jsx
@@ -225,7 +225,7 @@ export default class AdminSidebar extends React.Component {
>
<FormattedMessage
id='admin.sidebar.audits'
- defaultMessage='Audits'
+ defaultMessage='Compliance and Auditing'
/>
</a>
</li>
@@ -454,6 +454,7 @@ export default class AdminSidebar extends React.Component {
</ul>
<ul className='nav nav__sub-menu padded'>
{licenseSettings}
+ {audits}
<li>
<a
href='#'
@@ -466,7 +467,6 @@ export default class AdminSidebar extends React.Component {
/>
</a>
</li>
- {audits}
</ul>
</li>
</ul>
diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx
index 0a159d2e3..ec9ad4da0 100644
--- a/web/react/components/admin_console/analytics.jsx
+++ b/web/react/components/admin_console/analytics.jsx
@@ -1,7 +1,6 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import * as Utils from '../../utils/utils.jsx';
import Constants from '../../utils/constants.jsx';
import LineChart from './line_chart.jsx';
import DoughnutChart from './doughnut_chart.jsx';
@@ -10,7 +9,7 @@ import StatisticCount from './statistic_count.jsx';
var Tooltip = ReactBootstrap.Tooltip;
var OverlayTrigger = ReactBootstrap.OverlayTrigger;
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl';
+import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedDate} from 'mm-intl';
const holders = defineMessages({
analyticsTotalUsers: {
@@ -75,10 +74,12 @@ export default class Analytics extends React.Component {
}
let loading = (
- <FormattedMessage
- id='admin.analytics.loading'
- defaultMessage='Loading...'
- />
+ <h5>
+ <FormattedMessage
+ id='admin.analytics.loading'
+ defaultMessage='Loading...'
+ />
+ </h5>
);
let firstRow;
@@ -322,7 +323,17 @@ export default class Analytics extends React.Component {
</time>
</OverlayTrigger>
</td>
- <td>{Utils.displayDateTime(user.last_activity_at)}</td>
+ <td>
+ <FormattedDate
+ value={user.last_activity_at}
+ day='numeric'
+ month='long'
+ year='numeric'
+ hour12={true}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ </td>
</tr>
);
})
@@ -378,7 +389,17 @@ export default class Analytics extends React.Component {
</time>
</OverlayTrigger>
</td>
- <td>{Utils.displayDateTime(user.create_at)}</td>
+ <td>
+ <FormattedDate
+ value={user.create_at}
+ day='numeric'
+ month='long'
+ year='numeric'
+ hour12={true}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ </td>
</tr>
);
})
diff --git a/web/react/components/admin_console/audits.jsx b/web/react/components/admin_console/audits.jsx
index 866539b3d..173e63b45 100644
--- a/web/react/components/admin_console/audits.jsx
+++ b/web/react/components/admin_console/audits.jsx
@@ -60,8 +60,9 @@ export default class Audits extends React.Component {
<div style={{margin: '10px'}}>
<AuditTable
audits={this.state.audits}
- oneLine={true}
showUserId={true}
+ showIp={true}
+ showSession={true}
/>
</div>
);
@@ -72,7 +73,7 @@ export default class Audits extends React.Component {
<h3>
<FormattedMessage
id='admin.audits.title'
- defaultMessage='Server Audits'
+ defaultMessage='User Activity'
/>
</h3>
<button
diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx
index 17f25a04c..1d8f9c1dc 100644
--- a/web/react/components/admin_console/email_settings.jsx
+++ b/web/react/components/admin_console/email_settings.jsx
@@ -54,7 +54,7 @@ var holders = defineMessages({
},
pushServerEx: {
id: 'admin.email.pushServerEx',
- defaultMessage: 'E.g.: "https://push-test.mattermost.com"'
+ defaultMessage: 'E.g.: "http://push-test.mattermost.com"'
},
testing: {
id: 'admin.email.testing',
@@ -339,7 +339,10 @@ class EmailSettings extends React.Component {
defaultChecked={this.props.config.EmailSettings.EnableSignInWithEmail}
onChange={this.handleChange.bind(this, 'allowSignInWithEmail_true')}
/>
- {'true'}
+ <FormattedMessage
+ id='admin.email.true'
+ defaultMessage='true'
+ />
</label>
<label className='radio-inline'>
<input
@@ -349,7 +352,10 @@ class EmailSettings extends React.Component {
defaultChecked={!this.props.config.EmailSettings.EnableSignInWithEmail}
onChange={this.handleChange.bind(this, 'allowSignInWithEmail_false')}
/>
- {'false'}
+ <FormattedMessage
+ id='admin.email.false'
+ defaultMessage='false'
+ />
</label>
<p className='help-text'>
<FormattedMessage
@@ -380,7 +386,10 @@ class EmailSettings extends React.Component {
defaultChecked={this.props.config.EmailSettings.EnableSignInWithUsername}
onChange={this.handleChange.bind(this, 'allowSignInWithUsername_true')}
/>
- {'true'}
+ <FormattedMessage
+ id='admin.email.true'
+ defaultMessage='true'
+ />
</label>
<label className='radio-inline'>
<input
@@ -390,7 +399,10 @@ class EmailSettings extends React.Component {
defaultChecked={!this.props.config.EmailSettings.EnableSignInWithUsername}
onChange={this.handleChange.bind(this, 'allowSignInWithUsername_false')}
/>
- {'false'}
+ <FormattedMessage
+ id='admin.email.false'
+ defaultMessage='false'
+ />
</label>
<p className='help-text'>
<FormattedMessage
@@ -906,7 +918,7 @@ class EmailSettings extends React.Component {
<p className='help-text'>
<FormattedMessage
id='admin.email.pushServerDesc'
- defaultMessage='Location of Mattermost push notification service you can set up behind your firewall using https://github.com/mattermost/push-proxy. For testing you can use https://push-test.mattermost.com, which connects to the sample Mattermost iOS app in the public Apple AppStore. Please do not use test service for production deployments.'
+ defaultMessage='Location of Mattermost push notification service you can set up behind your firewall using https://github.com/mattermost/push-proxy. For testing you can use http://push-test.mattermost.com, which connects to the sample Mattermost iOS app in the public Apple AppStore. Please do not use test service for production deployments.'
/>
</p>
</div>
@@ -942,4 +954,4 @@ EmailSettings.propTypes = {
config: React.PropTypes.object
};
-export default injectIntl(EmailSettings); \ No newline at end of file
+export default injectIntl(EmailSettings);
diff --git a/web/react/components/admin_console/ldap_settings.jsx b/web/react/components/admin_console/ldap_settings.jsx
index bc13b3bcd..535c264dd 100644
--- a/web/react/components/admin_console/ldap_settings.jsx
+++ b/web/react/components/admin_console/ldap_settings.jsx
@@ -164,7 +164,7 @@ class LdapSettings extends React.Component {
<div className='banner__content'>
<FormattedHTMLMessage
id='admin.ldap.noLicense'
- defaultMessage='<h4 className="banner__heading">Note:</h4><p>LDAP is an enterprise feature. Your current license does not support LDAP. Click <a href="http://mattermost.com"target="_blank">here</a> for information and pricing on enterprise licenses.</p>'
+ defaultMessage='<h4 class="banner__heading">Note:</h4><p>LDAP is an enterprise feature. Your current license does not support LDAP. Click <a href="http://mattermost.com"target="_blank">here</a> for information and pricing on enterprise licenses.</p>'
/>
</div>
</div>
diff --git a/web/react/components/admin_console/license_settings.jsx b/web/react/components/admin_console/license_settings.jsx
index 539acd869..3332f37ef 100644
--- a/web/react/components/admin_console/license_settings.jsx
+++ b/web/react/components/admin_console/license_settings.jsx
@@ -109,7 +109,17 @@ class LicenseSettings extends React.Component {
);
licenseType = (
<FormattedHTMLMessage
- id='admin.license.entrepriseType'
+ id='admin.license.enterpriseType'
+ values={{
+ terms: global.window.mm_config.TermsOfServiceLink,
+ name: global.window.mm_license.Name,
+ company: global.window.mm_license.Company,
+ users: global.window.mm_license.Users,
+ issued: Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true),
+ start: Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10)),
+ expires: Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10)),
+ ldap: global.window.mm_license.LDAP
+ }}
defaultMessage='<div><p>This compiled release of Mattermost platform is provided under a <a href="http://mattermost.com" target="_blank">commercial license</a>
from Mattermost, Inc. based on your subscription level and is subject to the <a href="{terms}" target="_blank">Terms of Service.</a></p>
<p>Your subscription details are as follows:</p>
@@ -126,6 +136,7 @@ class LicenseSettings extends React.Component {
licenseKey = (
<div className='col-sm-8'>
<button
+ disabled={this.props.config.LdapSettings.Enable}
className='btn btn-danger'
onClick={this.handleRemove}
id='remove-button'
@@ -256,7 +267,8 @@ class LicenseSettings extends React.Component {
}
LicenseSettings.propTypes = {
- intl: intlShape.isRequired
+ intl: intlShape.isRequired,
+ config: React.PropTypes.object
};
-export default injectIntl(LicenseSettings); \ No newline at end of file
+export default injectIntl(LicenseSettings);
diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx
index 7021900eb..2cc68d1ed 100644
--- a/web/react/components/admin_console/service_settings.jsx
+++ b/web/react/components/admin_console/service_settings.jsx
@@ -75,6 +75,8 @@ class ServiceSettings extends React.Component {
config.ServiceSettings.EnableTesting = ReactDOM.findDOMNode(this.refs.EnableTesting).checked;
config.ServiceSettings.EnableDeveloper = ReactDOM.findDOMNode(this.refs.EnableDeveloper).checked;
config.ServiceSettings.EnableSecurityFixAlert = ReactDOM.findDOMNode(this.refs.EnableSecurityFixAlert).checked;
+ config.ServiceSettings.EnableCommands = ReactDOM.findDOMNode(this.refs.EnableCommands).checked;
+ config.ServiceSettings.EnableOnlyAdminIntegrations = ReactDOM.findDOMNode(this.refs.EnableOnlyAdminIntegrations).checked;
//config.ServiceSettings.EnableOAuthServiceProvider = ReactDOM.findDOMNode(this.refs.EnableOAuthServiceProvider).checked;
@@ -389,11 +391,105 @@ class ServiceSettings extends React.Component {
<div className='form-group'>
<label
className='control-label col-sm-4'
+ htmlFor='EnableCommands'
+ >
+ <FormattedMessage
+ id='admin.service.cmdsTitle'
+ defaultMessage='Enable Slash Commands: '
+ />
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableCommands'
+ value='true'
+ ref='EnableCommands'
+ defaultChecked={this.props.config.ServiceSettings.EnableCommands}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.true'
+ defaultMessage='true'
+ />
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableCommands'
+ value='false'
+ defaultChecked={!this.props.config.ServiceSettings.EnableCommands}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.false'
+ defaultMessage='false'
+ />
+ </label>
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.service.cmdsDesc'
+ defaultMessage='When true, user created slash commands will be allowed.'
+ />
+ </p>
+ </div>
+ </div>
+
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='EnableOnlyAdminIntegrations'
+ >
+ <FormattedMessage
+ id='admin.service.integrationAdmin'
+ defaultMessage='Enable Integrations for Admin Only: '
+ />
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableOnlyAdminIntegrations'
+ value='true'
+ ref='EnableOnlyAdminIntegrations'
+ defaultChecked={this.props.config.ServiceSettings.EnableOnlyAdminIntegrations}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.true'
+ defaultMessage='true'
+ />
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableOnlyAdminIntegrations'
+ value='false'
+ defaultChecked={!this.props.config.ServiceSettings.EnableOnlyAdminIntegrations}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.false'
+ defaultMessage='false'
+ />
+ </label>
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.service.integrationAdminDesc'
+ defaultMessage='When true, user created integrations can only be created by admins.'
+ />
+ </p>
+ </div>
+ </div>
+
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
htmlFor='EnablePostUsernameOverride'
>
<FormattedMessage
id='admin.service.overrideTitle'
- defaultMessage='Enable Overriding Usernames from Webhooks: '
+ defaultMessage='Enable Overriding Usernames from Webhooks and Slash Commands: '
/>
</label>
<div className='col-sm-8'>
@@ -427,7 +523,7 @@ class ServiceSettings extends React.Component {
<p className='help-text'>
<FormattedMessage
id='admin.service.overrideDescription'
- defaultMessage='When true, webhooks will be allowed to change the username they are posting as. Note, combined with allowing icon overriding, this could open users up to phishing attacks.'
+ defaultMessage='When true, webhooks and slash commands will be allowed to change the username they are posting as. Note, combined with allowing icon overriding, this could open users up to phishing attacks.'
/>
</p>
</div>
@@ -440,7 +536,7 @@ class ServiceSettings extends React.Component {
>
<FormattedMessage
id='admin.service.iconTitle'
- defaultMessage='Enable Overriding Icon from Webhooks: '
+ defaultMessage='Enable Overriding Icon from Webhooks and Slash Commands: '
/>
</label>
<div className='col-sm-8'>
@@ -474,7 +570,7 @@ class ServiceSettings extends React.Component {
<p className='help-text'>
<FormattedMessage
id='admin.service.iconDescription'
- defaultMessage='When true, webhooks will be allowed to change the icon they post with. Note, combined with allowing username overriding, this could open users up to phishing attacks.'
+ defaultMessage='When true, webhooks and slash commands will be allowed to change the icon they post with. Note, combined with allowing username overriding, this could open users up to phishing attacks.'
/>
</p>
</div>
diff --git a/web/react/components/audio_video_preview.jsx b/web/react/components/audio_video_preview.jsx
index 7d00fbdaa..739c8c95e 100644
--- a/web/react/components/audio_video_preview.jsx
+++ b/web/react/components/audio_video_preview.jsx
@@ -75,6 +75,7 @@ export default class AudioVideoPreview extends React.Component {
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
+ formatMessage={this.props.formatMessage}
/>
);
}
@@ -110,5 +111,6 @@ AudioVideoPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
- maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired
+ maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired,
+ formatMessage: React.PropTypes.func.isRequired
};
diff --git a/web/react/components/audit_table.jsx b/web/react/components/audit_table.jsx
index cdca7e8d6..49892ff98 100644
--- a/web/react/components/audit_table.jsx
+++ b/web/react/components/audit_table.jsx
@@ -183,389 +183,388 @@ const holders = defineMessages({
loginFailure: {
id: 'audit_table.loginFailure',
defaultMessage: ' (Login failure)'
- },
- userId: {
- id: 'audit_table.userId',
- defaultMessage: 'User ID'
}
});
class AuditTable extends React.Component {
constructor(props) {
super(props);
-
- this.handleMoreInfo = this.handleMoreInfo.bind(this);
- this.formatAuditInfo = this.formatAuditInfo.bind(this);
- this.handleRevokedSession = this.handleRevokedSession.bind(this);
-
- this.state = {moreInfo: []};
- }
- handleMoreInfo(index) {
- var newMoreInfo = this.state.moreInfo;
- newMoreInfo[index] = true;
- this.setState({moreInfo: newMoreInfo});
}
- handleRevokedSession(sessionId) {
- return this.props.intl.formatMessage(holders.sessionRevoked, {sessionId: sessionId});
- }
- formatAuditInfo(currentAudit) {
- const currentActionURL = currentAudit.action.replace(/\/api\/v[1-9]/, '');
+ render() {
+ var accessList = [];
const {formatMessage} = this.props.intl;
- let currentAuditDesc = '';
-
- if (currentActionURL.indexOf('/channels') === 0) {
- const channelInfo = currentAudit.extra_info.split(' ');
- const channelNameField = channelInfo[0].split('=');
-
- let channelURL = '';
- let channelObj;
- let channelName = '';
- if (channelNameField.indexOf('name') >= 0) {
- channelURL = channelNameField[channelNameField.indexOf('name') + 1];
- channelObj = ChannelStore.getByName(channelURL);
- if (channelObj) {
- channelName = channelObj.display_name;
- } else {
- channelName = channelURL;
- }
- }
+ for (var i = 0; i < this.props.audits.length; i++) {
+ const audit = this.props.audits[i];
+ const auditInfo = formatAuditInfo(audit, formatMessage);
- switch (currentActionURL) {
- case '/channels/create':
- currentAuditDesc = formatMessage(holders.channelCreated, {channelName: channelName});
- break;
- case '/channels/create_direct':
- currentAuditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username});
- break;
- case '/channels/update':
- currentAuditDesc = formatMessage(holders.nameUpdated, {channelName: channelName});
- break;
- case '/channels/update_desc': // support the old path
- case '/channels/update_header':
- currentAuditDesc = formatMessage(holders.headerUpdated, {channelName: channelName});
- break;
- default: {
- let userIdField = [];
- let userId = '';
- let username = '';
-
- if (channelInfo[1]) {
- userIdField = channelInfo[1].split('=');
-
- if (userIdField.indexOf('user_id') >= 0) {
- userId = userIdField[userIdField.indexOf('user_id') + 1];
- username = UserStore.getProfile(userId).username;
- }
- }
+ let uContent;
+ if (this.props.showUserId) {
+ uContent = <td>{auditInfo.userId}</td>;
+ }
- if (/\/channels\/[A-Za-z0-9]+\/delete/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.channelDeleted, {url: channelURL});
- } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName});
- } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName});
- }
+ let iContent;
+ if (this.props.showIp) {
+ iContent = <td>{auditInfo.ip}</td>;
+ }
- break;
+ let sContent;
+ if (this.props.showSession) {
+ sContent = <td>{auditInfo.sessionId}</td>;
}
+
+ let descStyle = {};
+ if (auditInfo.desc.toLowerCase().indexOf('fail') !== -1) {
+ descStyle.color = 'red';
}
- } else if (currentActionURL.indexOf('/oauth') === 0) {
- const oauthInfo = currentAudit.extra_info.split(' ');
- switch (currentActionURL) {
- case '/oauth/register': {
- const clientIdField = oauthInfo[0].split('=');
+ accessList[i] = (
+ <tr key={audit.id}>
+ <td>{auditInfo.timestamp}</td>
+ {uContent}
+ <td style={descStyle}>{auditInfo.desc}</td>
+ {iContent}
+ {sContent}
+ </tr>
+ );
+ }
- if (clientIdField[0] === 'client_id') {
- currentAuditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]});
- }
+ let userIdContent;
+ if (this.props.showUserId) {
+ userIdContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.userId'
+ defaultMessage='User ID'
+ />
+ </th>
+ );
+ }
- break;
- }
- case '/oauth/allow':
- if (oauthInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedAllowOAuthAccess);
- } else if (oauthInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullOAuthAccess);
- } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') {
- currentAuditDesc = formatMessage(holders.failedOAuthAccess);
- }
+ let ipContent;
+ if (this.props.showIp) {
+ ipContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.ip'
+ defaultMessage='IP Address'
+ />
+ </th>
+ );
+ }
- break;
- case '/oauth/access_token':
- if (oauthInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedOAuthToken);
- } else if (oauthInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullOAuthToken);
- } else {
- const oauthTokenFailure = oauthInfo[0].split('-');
-
- if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) {
- currentAuditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()});
- }
- }
+ let sessionContent;
+ if (this.props.showSession) {
+ sessionContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.session'
+ defaultMessage='Session ID'
+ />
+ </th>
+ );
+ }
- break;
- default:
- break;
- }
- } else if (currentActionURL.indexOf('/users') === 0) {
- const userInfo = currentAudit.extra_info.split(' ');
-
- switch (currentActionURL) {
- case '/users/login':
- if (userInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedLogin);
- } else if (userInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullLogin);
- } else if (userInfo[0]) {
- currentAuditDesc = formatMessage(holders.failedLogin);
- }
+ return (
+ <table className='table'>
+ <thead>
+ <tr>
+ <th>
+ <FormattedMessage
+ id='audit_table.timestamp'
+ defaultMessage='Timestamp'
+ />
+ </th>
+ {userIdContent}
+ <th>
+ <FormattedMessage
+ id='audit_table.action'
+ defaultMessage='Action'
+ />
+ </th>
+ {ipContent}
+ {sessionContent}
+ </tr>
+ </thead>
+ <tbody>
+ {accessList}
+ </tbody>
+ </table>
+ );
+ }
+}
- break;
- case '/users/revoke_session':
- currentAuditDesc = this.handleRevokedSession(userInfo[0].split('=')[1]);
- break;
- case '/users/newimage':
- currentAuditDesc = formatMessage(holders.updatePicture);
- break;
- case '/users/update':
- currentAuditDesc = formatMessage(holders.updateGeneral);
- break;
- case '/users/newpassword':
- if (userInfo[0] === 'attempted') {
- currentAuditDesc = formatMessage(holders.attemptedPassword);
- } else if (userInfo[0] === 'completed') {
- currentAuditDesc = formatMessage(holders.successfullPassword);
- } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') {
- currentAuditDesc = formatMessage(holders.failedPassword);
- }
+AuditTable.propTypes = {
+ intl: intlShape.isRequired,
+ audits: React.PropTypes.array.isRequired,
+ showUserId: React.PropTypes.bool,
+ showIp: React.PropTypes.bool,
+ showSession: React.PropTypes.bool
+};
- break;
- case '/users/update_roles': {
- const userRoles = userInfo[0].split('=')[1];
+export default injectIntl(AuditTable);
- currentAuditDesc = formatMessage(holders.updatedRol);
- if (userRoles.trim()) {
- currentAuditDesc += userRoles;
- } else {
- currentAuditDesc += formatMessage(holders.member);
+export function formatAuditInfo(audit, formatMessage) {
+ const actionURL = audit.action.replace(/\/api\/v[1-9]/, '');
+ let auditDesc = '';
+
+ if (actionURL.indexOf('/channels') === 0) {
+ const channelInfo = audit.extra_info.split(' ');
+ const channelNameField = channelInfo[0].split('=');
+
+ let channelURL = '';
+ let channelObj;
+ let channelName = '';
+ if (channelNameField.indexOf('name') >= 0) {
+ channelURL = channelNameField[channelNameField.indexOf('name') + 1];
+ channelObj = ChannelStore.getByName(channelURL);
+ if (channelObj) {
+ channelName = channelObj.display_name;
+ } else {
+ channelName = channelURL;
+ }
+ }
+
+ switch (actionURL) {
+ case '/channels/create':
+ auditDesc = formatMessage(holders.channelCreated, {channelName: channelName});
+ break;
+ case '/channels/create_direct':
+ auditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username});
+ break;
+ case '/channels/update':
+ auditDesc = formatMessage(holders.nameUpdated, {channelName: channelName});
+ break;
+ case '/channels/update_desc': // support the old path
+ case '/channels/update_header':
+ auditDesc = formatMessage(holders.headerUpdated, {channelName: channelName});
+ break;
+ default: {
+ let userIdField = [];
+ let userId = '';
+ let username = '';
+
+ if (channelInfo[1]) {
+ userIdField = channelInfo[1].split('=');
+
+ if (userIdField.indexOf('user_id') >= 0) {
+ userId = userIdField[userIdField.indexOf('user_id') + 1];
+ username = UserStore.getProfile(userId).username;
}
+ }
- break;
+ if (/\/channels\/[A-Za-z0-9]+\/delete/.test(actionURL)) {
+ auditDesc = formatMessage(holders.channelDeleted, {url: channelURL});
+ } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(actionURL)) {
+ auditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName});
+ } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(actionURL)) {
+ auditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName});
}
- case '/users/update_active': {
- const updateType = userInfo[0].split('=')[0];
- const updateField = userInfo[0].split('=')[1];
-
- /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */
- if (updateType === 'active') {
- if (updateField === 'true') {
- currentAuditDesc = formatMessage(holders.accountActive);
- } else if (updateField === 'false') {
- currentAuditDesc = formatMessage(holders.accountInactive);
- }
- const actingUserInfo = userInfo[1].split('=');
- if (actingUserInfo[0] === 'session_user') {
- const actingUser = UserStore.getProfile(actingUserInfo[1]);
- const currentUser = UserStore.getCurrentUser();
- if (currentUser && actingUser && (Utils.isAdmin(currentUser.roles) || Utils.isSystemAdmin(currentUser.roles))) {
- currentAuditDesc += formatMessage(holders.by, {username: actingUser.username});
- } else if (currentUser && actingUser) {
- currentAuditDesc += formatMessage(holders.byAdmin);
- }
- }
- } else if (updateType === 'session_id') {
- currentAuditDesc = this.handleRevokedSession(updateField);
- }
+ break;
+ }
+ }
+ } else if (actionURL.indexOf('/oauth') === 0) {
+ const oauthInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/oauth/register': {
+ const clientIdField = oauthInfo[0].split('=');
- break;
+ if (clientIdField[0] === 'client_id') {
+ auditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]});
}
- case '/users/send_password_reset':
- currentAuditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]});
- break;
- case '/users/reset_password':
- if (userInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedReset);
- } else if (userInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullReset);
- }
- break;
- case '/users/update_notify':
- currentAuditDesc = formatMessage(holders.updateGlobalNotifications);
- break;
- default:
- break;
+ break;
+ }
+ case '/oauth/allow':
+ if (oauthInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedAllowOAuthAccess);
+ } else if (oauthInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullOAuthAccess);
+ } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') {
+ auditDesc = formatMessage(holders.failedOAuthAccess);
}
- } else if (currentActionURL.indexOf('/hooks') === 0) {
- const webhookInfo = currentAudit.extra_info.split(' ');
-
- switch (currentActionURL) {
- case '/hooks/incoming/create':
- if (webhookInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedWebhookCreate);
- } else if (webhookInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.succcessfullWebhookCreate);
- } else if (webhookInfo[0] === 'fail - bad channel permissions') {
- currentAuditDesc = formatMessage(holders.failedWebhookCreate);
- }
- break;
- case '/hooks/incoming/delete':
- if (webhookInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedWebhookDelete);
- } else if (webhookInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullWebhookDelete);
- } else if (webhookInfo[0] === 'fail - inappropriate conditions') {
- currentAuditDesc = formatMessage(holders.failedWebhookDelete);
+ break;
+ case '/oauth/access_token':
+ if (oauthInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedOAuthToken);
+ } else if (oauthInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullOAuthToken);
+ } else {
+ const oauthTokenFailure = oauthInfo[0].split('-');
+
+ if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) {
+ auditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()});
}
+ }
- break;
- default:
- break;
+ break;
+ default:
+ break;
+ }
+ } else if (actionURL.indexOf('/users') === 0) {
+ const userInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/users/login':
+ if (userInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedLogin);
+ } else if (userInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullLogin);
+ } else if (userInfo[0]) {
+ auditDesc = formatMessage(holders.failedLogin);
}
- } else {
- switch (currentActionURL) {
- case '/logout':
- currentAuditDesc = formatMessage(holders.logout);
- break;
- case '/verify_email':
- currentAuditDesc = formatMessage(holders.verified);
- break;
- default:
- break;
+
+ break;
+ case '/users/revoke_session':
+ auditDesc = formatMessage(holders.sessionRevoked, {sessionId: userInfo[0].split('=')[1]});
+ break;
+ case '/users/newimage':
+ auditDesc = formatMessage(holders.updatePicture);
+ break;
+ case '/users/update':
+ auditDesc = formatMessage(holders.updateGeneral);
+ break;
+ case '/users/newpassword':
+ if (userInfo[0] === 'attempted') {
+ auditDesc = formatMessage(holders.attemptedPassword);
+ } else if (userInfo[0] === 'completed') {
+ auditDesc = formatMessage(holders.successfullPassword);
+ } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') {
+ auditDesc = formatMessage(holders.failedPassword);
}
- }
- /* If all else fails... */
- if (!currentAuditDesc) {
- /* Currently not called anywhere */
- if (currentAudit.extra_info.indexOf('revoked_all=') >= 0) {
- currentAuditDesc = formatMessage(holders.revokedAll);
+ break;
+ case '/users/update_roles': {
+ const userRoles = userInfo[0].split('=')[1];
+
+ auditDesc = formatMessage(holders.updatedRol);
+ if (userRoles.trim()) {
+ auditDesc += userRoles;
} else {
- let currentActionDesc = '';
- if (currentActionURL && currentActionURL.lastIndexOf('/') !== -1) {
- currentActionDesc = currentActionURL.substring(currentActionURL.lastIndexOf('/') + 1).replace('_', ' ');
- currentActionDesc = Utils.toTitleCase(currentActionDesc);
- }
+ auditDesc += formatMessage(holders.member);
+ }
- let currentExtraInfoDesc = '';
- if (currentAudit.extra_info) {
- currentExtraInfoDesc = currentAudit.extra_info;
+ break;
+ }
+ case '/users/update_active': {
+ const updateType = userInfo[0].split('=')[0];
+ const updateField = userInfo[0].split('=')[1];
+
+ /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */
+ if (updateType === 'active') {
+ if (updateField === 'true') {
+ auditDesc = formatMessage(holders.accountActive);
+ } else if (updateField === 'false') {
+ auditDesc = formatMessage(holders.accountInactive);
+ }
- if (currentExtraInfoDesc.indexOf('=') !== -1) {
- currentExtraInfoDesc = currentExtraInfoDesc.substring(currentExtraInfoDesc.indexOf('=') + 1);
+ const actingUserInfo = userInfo[1].split('=');
+ if (actingUserInfo[0] === 'session_user') {
+ const actingUser = UserStore.getProfile(actingUserInfo[1]);
+ const user = UserStore.getCurrentUser();
+ if (user && actingUser && (Utils.isAdmin(user.roles) || Utils.isSystemAdmin(user.roles))) {
+ auditDesc += formatMessage(holders.by, {username: actingUser.username});
+ } else if (user && actingUser) {
+ auditDesc += formatMessage(holders.byAdmin);
}
}
- currentAuditDesc = currentActionDesc + ' ' + currentExtraInfoDesc;
+ } else if (updateType === 'session_id') {
+ auditDesc = formatMessage(holders.sessionRevoked, {sessionId: updateField});
}
- }
- const currentDate = new Date(currentAudit.create_at);
- let currentAuditInfo = currentDate.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + currentDate.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'});
+ break;
+ }
+ case '/users/send_password_reset':
+ auditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]});
+ break;
+ case '/users/reset_password':
+ if (userInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedReset);
+ } else if (userInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullReset);
+ }
- if (this.props.showUserId) {
- currentAuditInfo += ' | ' + formatMessage(holders.userId) + ': ' + currentAudit.user_id;
+ break;
+ case '/users/update_notify':
+ auditDesc = formatMessage(holders.updateGlobalNotifications);
+ break;
+ default:
+ break;
}
+ } else if (actionURL.indexOf('/hooks') === 0) {
+ const webhookInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/hooks/incoming/create':
+ if (webhookInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedWebhookCreate);
+ } else if (webhookInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.succcessfullWebhookCreate);
+ } else if (webhookInfo[0] === 'fail - bad channel permissions') {
+ auditDesc = formatMessage(holders.failedWebhookCreate);
+ }
- currentAuditInfo += ' | ' + currentAuditDesc;
+ break;
+ case '/hooks/incoming/delete':
+ if (webhookInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedWebhookDelete);
+ } else if (webhookInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullWebhookDelete);
+ } else if (webhookInfo[0] === 'fail - inappropriate conditions') {
+ auditDesc = formatMessage(holders.failedWebhookDelete);
+ }
- return currentAuditInfo;
+ break;
+ default:
+ break;
+ }
+ } else {
+ switch (actionURL) {
+ case '/logout':
+ auditDesc = formatMessage(holders.logout);
+ break;
+ case '/verify_email':
+ auditDesc = formatMessage(holders.verified);
+ break;
+ default:
+ break;
+ }
}
- render() {
- var accessList = [];
- const {formatMessage} = this.props.intl;
- for (var i = 0; i < this.props.audits.length; i++) {
- const currentAudit = this.props.audits[i];
- const currentAuditInfo = this.formatAuditInfo(currentAudit);
-
- let moreInfo;
- if (!this.props.oneLine) {
- moreInfo = (
- <a
- href='#'
- className='theme'
- onClick={this.handleMoreInfo.bind(this, i)}
- >
- <FormattedMessage
- id='audit_table.moreInfo'
- defaultMessage='More info'
- />
- </a>
- );
+ /* If all else fails... */
+ if (!auditDesc) {
+ /* Currently not called anywhere */
+ if (audit.extra_info.indexOf('revoked_all=') >= 0) {
+ auditDesc = formatMessage(holders.revokedAll);
+ } else {
+ let actionDesc = '';
+ if (actionURL && actionURL.lastIndexOf('/') !== -1) {
+ actionDesc = actionURL.substring(actionURL.lastIndexOf('/') + 1).replace('_', ' ');
+ actionDesc = Utils.toTitleCase(actionDesc);
}
- if (this.state.moreInfo[i]) {
- if (!currentAudit.session_id) {
- currentAudit.session_id = 'N/A';
+ let extraInfoDesc = '';
+ if (audit.extra_info) {
+ extraInfoDesc = audit.extra_info;
- if (currentAudit.action.search('/users/login') >= 0) {
- if (currentAudit.extra_info === 'attempt') {
- currentAudit.session_id += formatMessage(holders.loginAttempt);
- } else {
- currentAudit.session_id += formatMessage(holders.loginFailure);
- }
- }
+ if (extraInfoDesc.indexOf('=') !== -1) {
+ extraInfoDesc = extraInfoDesc.substring(extraInfoDesc.indexOf('=') + 1);
}
-
- moreInfo = (
- <div>
- <div>
- <FormattedMessage
- id='audit_table.ip'
- defaultMessage='IP: {ip}'
- values={{
- ip: currentAudit.ip_address
- }}
- />
- </div>
- <div>
- <FormattedMessage
- id='audit_table.session'
- defaultMessage='Session ID: {id}'
- values={{
- id: currentAudit.session_id
- }}
- />
- </div>
- </div>
- );
}
-
- var divider = null;
- if (i < this.props.audits.length - 1) {
- divider = (<div className='divider-light'></div>);
- }
-
- accessList[i] = (
- <div
- key={'accessHistoryEntryKey' + i}
- className='access-history__table'
- >
- <div className='access__report'>
- <div className='report__time'>{currentAuditInfo}</div>
- <div className='report__info'>
- {moreInfo}
- </div>
- {divider}
- </div>
- </div>
- );
+ auditDesc = actionDesc + ' ' + extraInfoDesc;
}
-
- return <form role='form'>{accessList}</form>;
}
-}
-AuditTable.propTypes = {
- intl: intlShape.isRequired,
- audits: React.PropTypes.array.isRequired,
- oneLine: React.PropTypes.bool,
- showUserId: React.PropTypes.bool
-};
+ const date = new Date(audit.create_at);
+ let auditInfo = {};
+ auditInfo.timestamp = date.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + date.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'});
+ auditInfo.userId = audit.user_id;
+ auditInfo.desc = auditDesc;
+ auditInfo.ip = audit.ip_address;
+ auditInfo.sessionId = audit.session_id;
-export default injectIntl(AuditTable);
+ return auditInfo;
+}
diff --git a/web/react/components/center_panel.jsx b/web/react/components/center_panel.jsx
index 443ecefde..7d2be04d6 100644
--- a/web/react/components/center_panel.jsx
+++ b/web/react/components/center_panel.jsx
@@ -15,6 +15,8 @@ import UserStore from '../stores/user_store.jsx';
import * as Utils from '../utils/utils.jsx';
+import {FormattedMessage} from 'mm-intl';
+
import Constants from '../utils/constants.jsx';
const TutorialSteps = Constants.TutorialSteps;
const Preferences = Constants.Preferences;
@@ -69,8 +71,11 @@ export default class CenterPanel extends React.Component {
onClick={handleClick}
>
<a href=''>
- {'Click here to jump to recent messages. '}
- {<i className='fa fa-arrow-down'></i>}
+ <FormattedMessage
+ id='center_panel.recent'
+ defaultMessage='Click here to jump to recent messages. '
+ />
+ <i className='fa fa-arrow-down'></i>
</a>
</div>
);
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index 005a82209..8fc3cd63d 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -419,7 +419,7 @@ export default class ChannelHeader extends React.Component {
</ul>
</div>
<OverlayTrigger
- trigger={['hover', 'focus']}
+ trigger={'click'}
placement='bottom'
overlay={popoverContent}
ref='headerOverlay'
diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx
index 712d6885f..174c8c4e1 100644
--- a/web/react/components/channel_loader.jsx
+++ b/web/react/components/channel_loader.jsx
@@ -15,7 +15,40 @@ import PreferenceStore from '../stores/preference_store.jsx';
import * as Utils from '../utils/utils.jsx';
import Constants from '../utils/constants.jsx';
-export default class ChannelLoader extends React.Component {
+import {intlShape, injectIntl, defineMessages} from 'mm-intl';
+
+const holders = defineMessages({
+ socketError: {
+ id: 'channel_loader.socketError',
+ defaultMessage: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'
+ },
+ someone: {
+ id: 'channel_loader.someone',
+ defaultMessage: 'Someone'
+ },
+ posted: {
+ id: 'channel_loader.posted',
+ defaultMessage: 'Posted'
+ },
+ uploadedImage: {
+ id: 'channel_loader.uploadedImage',
+ defaultMessage: ' uploaded an image'
+ },
+ uploadedFile: {
+ id: 'channel_loader.uploadedFile',
+ defaultMessage: ' uploaded a file'
+ },
+ something: {
+ id: 'channel_loader.something',
+ defaultMessage: ' did something new'
+ },
+ wrote: {
+ id: 'channel_loader.wrote',
+ defaultMessage: ' wrote: '
+ }
+});
+
+class ChannelLoader extends React.Component {
constructor(props) {
super(props);
@@ -23,6 +56,17 @@ export default class ChannelLoader extends React.Component {
this.onSocketChange = this.onSocketChange.bind(this);
+ const {formatMessage} = this.props.intl;
+ SocketStore.setTranslations({
+ socketError: formatMessage(holders.socketError),
+ someone: formatMessage(holders.someone),
+ posted: formatMessage(holders.posted),
+ uploadedImage: formatMessage(holders.uploadedImage),
+ uploadedFile: formatMessage(holders.uploadedFile),
+ something: formatMessage(holders.something),
+ wrote: formatMessage(holders.wrote)
+ });
+
this.state = {};
}
componentDidMount() {
@@ -84,6 +128,16 @@ export default class ChannelLoader extends React.Component {
}
});
+ $('body').on('mouseenter mouseleave', '.search-item__container .post', function mouseOver(ev) {
+ if (ev.type === 'mouseenter') {
+ $(this).closest('.search-item__container').find('.date-separator').addClass('hovered--after');
+ $(this).closest('.search-item__container').next('div').find('.date-separator').addClass('hovered--before');
+ } else {
+ $(this).closest('.search-item__container').find('.date-separator').removeClass('hovered--after');
+ $(this).closest('.search-item__container').next('div').find('.date-separator').removeClass('hovered--before');
+ }
+ });
+
$('body').on('mouseenter mouseleave', '.post.post--comment.same--root', function mouseOver(ev) {
if (ev.type === 'mouseenter') {
$(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--comment');
@@ -126,3 +180,9 @@ export default class ChannelLoader extends React.Component {
return <div/>;
}
}
+
+ChannelLoader.propTypes = {
+ intl: intlShape.isRequired
+};
+
+export default injectIntl(ChannelLoader); \ No newline at end of file
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index 1b552838a..9e7c67515 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -51,6 +51,7 @@ class CreateComment extends React.Component {
this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -74,6 +75,8 @@ class CreateComment extends React.Component {
componentDidMount() {
PreferenceStore.addChangeListener(this.onPreferenceChange);
window.addEventListener('resize', this.handleResize);
+
+ this.refs.textbox.focus();
}
componentWillUnmount() {
PreferenceStore.removeChangeListener(this.onPreferenceChange);
@@ -94,6 +97,10 @@ class CreateComment extends React.Component {
$('.post-right__scroll').perfectScrollbar('update');
}
}
+
+ if (prevProps.rootId !== this.props.rootId) {
+ this.refs.textbox.focus();
+ }
}
handleSubmit(e) {
e.preventDefault();
@@ -202,8 +209,7 @@ class CreateComment extends React.Component {
if (e.keyCode === KeyCodes.UP && this.state.messageText === '') {
e.preventDefault();
- const channelId = ChannelStore.getCurrentId();
- const lastPost = PostStore.getCurrentUsersLatestPost(channelId, this.props.rootId);
+ const lastPost = PostStore.getCurrentUsersLatestPost(this.props.channelId, this.props.rootId);
if (!lastPost) {
return;
}
@@ -219,6 +225,9 @@ class CreateComment extends React.Component {
});
}
}
+ handleUploadClick() {
+ this.refs.textbox.focus();
+ }
handleUploadStart(clientIds) {
let draft = PostStore.getCommentDraft(this.props.rootId);
@@ -226,6 +235,10 @@ class CreateComment extends React.Component {
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress});
+
+ // this is a bit redundant with the code that sets focus when the file input is clicked,
+ // but this also resets the focus after a drag and drop
+ this.refs.textbox.focus();
}
handleFileUploadComplete(filenames, clientIds) {
let draft = PostStore.getCommentDraft(this.props.rootId);
@@ -366,6 +379,7 @@ class CreateComment extends React.Component {
<FileUpload
ref='fileUpload'
getFileCount={this.getFileCount}
+ onClick={this.handleUploadClick}
onUploadStart={this.handleUploadStart}
onFileUpload={this.handleFileUploadComplete}
onUploadError={this.handleUploadError}
@@ -402,4 +416,4 @@ CreateComment.propTypes = {
rootId: React.PropTypes.string.isRequired
};
-export default injectIntl(CreateComment); \ No newline at end of file
+export default injectIntl(CreateComment);
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index de971c43f..6ea80cd13 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -21,12 +21,29 @@ import SocketStore from '../stores/socket_store.jsx';
import Constants from '../utils/constants.jsx';
+import {intlShape, injectIntl, defineMessages, FormattedHTMLMessage} from 'mm-intl';
+
const Preferences = Constants.Preferences;
const TutorialSteps = Constants.TutorialSteps;
const ActionTypes = Constants.ActionTypes;
const KeyCodes = Constants.KeyCodes;
-export default class CreatePost extends React.Component {
+const holders = defineMessages({
+ comment: {
+ id: 'create_post.comment',
+ defaultMessage: 'Comment'
+ },
+ post: {
+ id: 'create_post.post',
+ defaultMessage: 'Post'
+ },
+ write: {
+ id: 'create_post.write',
+ defaultMessage: 'Write a message...'
+ }
+});
+
+class CreatePost extends React.Component {
constructor(props) {
super(props);
@@ -36,7 +53,7 @@ export default class CreatePost extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.postMsgKeyPress = this.postMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
- this.resizePostHolder = this.resizePostHolder.bind(this);
+ this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -45,7 +62,6 @@ export default class CreatePost extends React.Component {
this.onPreferenceChange = this.onPreferenceChange.bind(this);
this.getFileCount = this.getFileCount.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
- this.handleResize = this.handleResize.bind(this);
this.sendMessage = this.sendMessage.bind(this);
PostStore.clearDraftUploads();
@@ -59,34 +75,10 @@ export default class CreatePost extends React.Component {
previews: draft.previews,
submitting: false,
initialText: draft.messageText,
- windowWidth: Utils.windowWidth(),
- windowHeight: Utils.windowHeight(),
ctrlSend: false,
showTutorialTip: false
};
}
- handleResize() {
- this.setState({
- windowWidth: Utils.windowWidth(),
- windowHeight: Utils.windowHeight()
- });
- }
- componentDidUpdate(prevProps, prevState) {
- if (prevState.previews.length !== this.state.previews.length) {
- this.resizePostHolder();
- return;
- }
-
- if (prevState.uploadsInProgress !== this.state.uploadsInProgress) {
- this.resizePostHolder();
- return;
- }
-
- if (prevState.windowWidth !== this.state.windowWidth || prevState.windowHeight !== this.state.windowHeight) {
- this.resizePostHolder();
- return;
- }
- }
getCurrentDraft() {
const draft = PostStore.getCurrentDraft();
const safeDraft = {previews: [], messageText: '', uploadsInProgress: []};
@@ -133,15 +125,10 @@ export default class CreatePost extends React.Component {
post.message,
false,
(data) => {
- if (data.response === 'not implemented') {
- this.sendMessage(post);
- return;
- }
-
PostStore.storeDraft(data.channel_id, null);
this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
- if (data.goto_location.length > 0) {
+ if (data.goto_location && data.goto_location.length > 0) {
window.location.href = data.goto_location;
}
},
@@ -228,10 +215,8 @@ export default class CreatePost extends React.Component {
draft.message = messageText;
PostStore.storeCurrentDraft(draft);
}
- resizePostHolder() {
- if (this.state.windowWidth > 960) {
- $('#post_textbox').focus();
- }
+ handleUploadClick() {
+ this.refs.textbox.focus();
}
handleUploadStart(clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
@@ -240,6 +225,10 @@ export default class CreatePost extends React.Component {
PostStore.storeDraft(channelId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress});
+
+ // this is a bit redundant with the code that sets focus when the file input is clicked,
+ // but this also resets the focus after a drag and drop
+ this.refs.textbox.focus();
}
handleFileUploadComplete(filenames, clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
@@ -316,13 +305,16 @@ export default class CreatePost extends React.Component {
componentDidMount() {
ChannelStore.addChangeListener(this.onChange);
PreferenceStore.addChangeListener(this.onPreferenceChange);
- this.resizePostHolder();
- window.addEventListener('resize', this.handleResize);
+ this.refs.textbox.focus();
+ }
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.channelId !== this.state.channelId) {
+ this.refs.textbox.focus();
+ }
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onChange);
PreferenceStore.removeChangeListener(this.onPreferenceChange);
- window.removeEventListener('resize', this.handleResize);
}
onChange() {
const channelId = ChannelStore.getCurrentId();
@@ -361,7 +353,8 @@ export default class CreatePost extends React.Component {
if (!lastPost) {
return;
}
- var type = (lastPost.root_id && lastPost.root_id.length > 0) ? 'Comment' : 'Post';
+ const {formatMessage} = this.props.intl;
+ var type = (lastPost.root_id && lastPost.root_id.length > 0) ? formatMessage(holders.comment) : formatMessage(holders.post);
AppDispatcher.handleViewAction({
type: ActionTypes.RECIEVED_EDIT_POST,
@@ -379,9 +372,10 @@ export default class CreatePost extends React.Component {
screens.push(
<div>
- <h4>{'Sending Messages'}</h4>
- <p>{'Type here to write a message and press '}<strong>{'Enter'}</strong>{' to post it.'}</p>
- <p>{'Click the '}<strong>{'Attachment'}</strong>{' button to upload an image or a file.'}</p>
+ <FormattedHTMLMessage
+ id='create_post.tutorialTip'
+ defaultMessage='<h4>Sending Messages</h4><p>Type here to write a message and press <strong>Enter</strong> to post it.</p><p>Click the <strong>Attachment</strong> button to upload an image or a file.</p>'
+ />
</div>
);
@@ -443,9 +437,8 @@ export default class CreatePost extends React.Component {
onUserInput={this.handleUserInput}
onKeyPress={this.postMsgKeyPress}
onKeyDown={this.handleKeyDown}
- onHeightChange={this.resizePostHolder}
messageText={this.state.messageText}
- createMessage='Write a message...'
+ createMessage={this.props.intl.formatMessage(holders.write)}
channelId={this.state.channelId}
id='post_textbox'
ref='textbox'
@@ -453,6 +446,7 @@ export default class CreatePost extends React.Component {
<FileUpload
ref='fileUpload'
getFileCount={this.getFileCount}
+ onClick={this.handleUploadClick}
onUploadStart={this.handleUploadStart}
onFileUpload={this.handleFileUploadComplete}
onUploadError={this.handleUploadError}
@@ -482,3 +476,9 @@ export default class CreatePost extends React.Component {
);
}
}
+
+CreatePost.propTypes = {
+ intl: intlShape.isRequired
+};
+
+export default injectIntl(CreatePost);
diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx
index 34fd724f5..9d7dcb3e5 100644
--- a/web/react/components/delete_post_modal.jsx
+++ b/web/react/components/delete_post_modal.jsx
@@ -88,7 +88,7 @@ export default class DeletePostModal extends React.Component {
}
}
- PostStore.removePost(this.state.post.id, this.state.post.channel_id);
+ PostStore.deletePost(this.state.post);
AsyncClient.getPosts(this.state.post.channel_id);
},
(err) => {
diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx
index eeb218bfe..776394828 100644
--- a/web/react/components/file_attachment.jsx
+++ b/web/react/components/file_attachment.jsx
@@ -5,7 +5,16 @@ import * as utils from '../utils/utils.jsx';
import * as Client from '../utils/client.jsx';
import Constants from '../utils/constants.jsx';
-export default class FileAttachment extends React.Component {
+import {intlShape, injectIntl, defineMessages} from 'mm-intl';
+
+const holders = defineMessages({
+ download: {
+ id: 'file_attachment.download',
+ defaultMessage: 'Download'
+ }
+});
+
+class FileAttachment extends React.Component {
constructor(props) {
super(props);
@@ -266,7 +275,7 @@ export default class FileAttachment extends React.Component {
href={fileUrl}
download={filenameString}
data-toggle='tooltip'
- title={'Download \"' + filenameString + '\"'}
+ title={this.props.intl.formatMessage(holders.download) + ' \"' + filenameString + '\"'}
className='post-image__name'
>
{trimmedFilename}
@@ -291,6 +300,7 @@ export default class FileAttachment extends React.Component {
}
FileAttachment.propTypes = {
+ intl: intlShape.isRequired,
// a list of file pathes displayed by the parent FileAttachmentList
filename: React.PropTypes.string.isRequired,
@@ -301,3 +311,5 @@ FileAttachment.propTypes = {
// handler for when the thumbnail is clicked passed the index above
handleImageClick: React.PropTypes.func
};
+
+export default injectIntl(FileAttachment); \ No newline at end of file
diff --git a/web/react/components/file_info_preview.jsx b/web/react/components/file_info_preview.jsx
index 45d89007f..1dac140c9 100644
--- a/web/react/components/file_info_preview.jsx
+++ b/web/react/components/file_info_preview.jsx
@@ -3,15 +3,28 @@
import * as Utils from '../utils/utils.jsx';
-export default function FileInfoPreview({filename, fileUrl, fileInfo}) {
+import {defineMessages} from 'mm-intl';
+
+const holders = defineMessages({
+ type: {
+ id: 'file_info_preview.type',
+ defaultMessage: 'File type '
+ },
+ size: {
+ id: 'file_info_preview.size',
+ defaultMessage: 'Size '
+ }
+});
+
+export default function FileInfoPreview({filename, fileUrl, fileInfo, formatMessage}) {
// non-image files include a section providing details about the file
const infoParts = [];
if (fileInfo.extension !== '') {
- infoParts.push('File type ' + fileInfo.extension.toUpperCase());
+ infoParts.push(formatMessage(holders.type) + fileInfo.extension.toUpperCase());
}
- infoParts.push('Size ' + Utils.fileSizeToString(fileInfo.size));
+ infoParts.push(formatMessage(holders.size) + Utils.fileSizeToString(fileInfo.size));
const infoString = infoParts.join(', ');
diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx
index 626dbc5b3..f5c32c825 100644
--- a/web/react/components/file_upload.jsx
+++ b/web/react/components/file_upload.jsx
@@ -101,9 +101,9 @@ class FileUpload extends React.Component {
} else if (tooLargeFiles.length > 1) {
var tooLargeFilenames = tooLargeFiles.map((file) => file.name).join(', ');
- this.props.onUploadError(formatMessage(holders.filesAbove, {max: (Constants.MAX_FILE_SIZE / 1000000), files: tooLargeFilenames}));
+ this.props.onUploadError(formatMessage(holders.filesAbove, {max: (Constants.MAX_FILE_SIZE / 1000000), filenames: tooLargeFilenames}));
} else if (tooLargeFiles.length > 0) {
- this.props.onUploadError(formatMessage(holders.fileAbove, {max: (Constants.MAX_FILE_SIZE / 1000000), file: tooLargeFiles[0].name}));
+ this.props.onUploadError(formatMessage(holders.fileAbove, {max: (Constants.MAX_FILE_SIZE / 1000000), filename: tooLargeFiles[0].name}));
}
}
@@ -310,6 +310,7 @@ class FileUpload extends React.Component {
ref='fileInput'
type='file'
onChange={this.handleChange}
+ onClick={this.props.onClick}
multiple={multiple}
accept={accept}
/>
@@ -322,6 +323,7 @@ FileUpload.propTypes = {
intl: intlShape.isRequired,
onUploadError: React.PropTypes.func,
getFileCount: React.PropTypes.func,
+ onClick: React.PropTypes.func,
onFileUpload: React.PropTypes.func,
onUploadStart: React.PropTypes.func,
onTextDrop: React.PropTypes.func,
@@ -329,4 +331,4 @@ FileUpload.propTypes = {
postType: React.PropTypes.string
};
-export default injectIntl(FileUpload); \ No newline at end of file
+export default injectIntl(FileUpload);
diff --git a/web/react/components/login_username.jsx b/web/react/components/login_username.jsx
index f787490fa..4bd9254c6 100644
--- a/web/react/components/login_username.jsx
+++ b/web/react/components/login_username.jsx
@@ -89,9 +89,9 @@ export default class LoginUsername extends React.Component {
}
},
(err) => {
- if (err.message === 'api.user.login.not_verified.app_error') {
+ if (err.id === 'api.user.login.not_verified.app_error') {
state.serverError = formatMessage(holders.verifyEmailError);
- } else if (err.message === 'store.sql_user.get_by_username.app_error') {
+ } else if (err.id === 'store.sql_user.get_by_username.app_error') {
state.serverError = formatMessage(holders.userNotFoundError);
} else {
state.serverError = err.message;
diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx
index 7326a9ef8..8005678a2 100644
--- a/web/react/components/navbar.jsx
+++ b/web/react/components/navbar.jsx
@@ -24,6 +24,8 @@ import Constants from '../utils/constants.jsx';
const ActionTypes = Constants.ActionTypes;
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
+import {FormattedMessage} from 'mm-intl';
+
const Popover = ReactBootstrap.Popover;
const OverlayTrigger = ReactBootstrap.OverlayTrigger;
@@ -133,7 +135,10 @@ export default class Navbar extends React.Component {
dialogType={ChannelInfoModal}
dialogProps={{channel}}
>
- {'View Info'}
+ <FormattedMessage
+ id='navbar.viewInfo'
+ defaultMessage='View Info'
+ />
</ToggleModalButton>
</li>
);
@@ -145,7 +150,10 @@ export default class Navbar extends React.Component {
href='#'
onClick={this.showEditChannelHeaderModal}
>
- {'Set Channel Header...'}
+ <FormattedMessage
+ id='navbar.setHeader'
+ defaultMessage='Set Channel Header...'
+ />
</a>
</li>
);
@@ -159,7 +167,10 @@ export default class Navbar extends React.Component {
href='#'
onClick={() => this.setState({showEditChannelPurposeModal: true})}
>
- {'Set Channel Purpose...'}
+ <FormattedMessage
+ id='navbar.setPurpose'
+ defaultMessage='Set Channel Purpose...'
+ />
</a>
</li>
);
@@ -175,7 +186,10 @@ export default class Navbar extends React.Component {
dialogType={ChannelInviteModal}
dialogProps={{channel}}
>
- {'Add Members'}
+ <FormattedMessage
+ id='navbar.addMembers'
+ defaultMessage='Add Members'
+ />
</ToggleModalButton>
</li>
);
@@ -187,7 +201,10 @@ export default class Navbar extends React.Component {
href='#'
onClick={this.handleLeave}
>
- {'Leave Channel'}
+ <FormattedMessage
+ id='navbar.leave'
+ defaultMessage='Leave Channel'
+ />
</a>
</li>
);
@@ -205,7 +222,10 @@ export default class Navbar extends React.Component {
href='#'
onClick={() => this.setState({showMembersModal: true})}
>
- {'Manage Members'}
+ <FormattedMessage
+ id='navbar.manageMembers'
+ defaultMessage='Manage Members'
+ />
</a>
</li>
);
@@ -217,7 +237,10 @@ export default class Navbar extends React.Component {
dialogType={DeleteChannelModal}
dialogProps={{channel}}
>
- {'Delete Channel...'}
+ <FormattedMessage
+ id='navbar.delete'
+ defaultMessage='Delete Channel...'
+ />
</ToggleModalButton>
</li>
);
@@ -234,7 +257,10 @@ export default class Navbar extends React.Component {
data-name={channel.name}
data-channelid={channel.id}
>
- {'Rename Channel...'}
+ <FormattedMessage
+ id='navbar.rename'
+ defaultMessage='Rename Channel...'
+ />
</a>
</li>
);
@@ -249,7 +275,10 @@ export default class Navbar extends React.Component {
dialogType={ChannelNotificationsModal}
dialogProps={{channel}}
>
- {'Notification Preferences'}
+ <FormattedMessage
+ id='navbar.preferences'
+ defaultMessage='Notification Preferences'
+ />
</ToggleModalButton>
</li>
);
@@ -319,7 +348,12 @@ export default class Navbar extends React.Component {
data-toggle='collapse'
data-target='#navbar-collapse-1'
>
- <span className='sr-only'>{'Toggle sidebar'}</span>
+ <span className='sr-only'>
+ <FormattedMessage
+ id='navbar.toggle1'
+ defaultMessage='Toggle sidebar'
+ />
+ </span>
<span className='icon-bar'></span>
<span className='icon-bar'></span>
<span className='icon-bar'></span>
@@ -335,7 +369,12 @@ export default class Navbar extends React.Component {
data-target='#sidebar-nav'
onClick={this.toggleLeftSidebar}
>
- <span className='sr-only'>{'Toggle sidebar'}</span>
+ <span className='sr-only'>
+ <FormattedMessage
+ id='navbar.toggle2'
+ defaultMessage='Toggle sidebar'
+ />
+ </span>
<span className='icon-bar'></span>
<span className='icon-bar'></span>
<span className='icon-bar'></span>
@@ -405,6 +444,17 @@ export default class Navbar extends React.Component {
}
if (channel.header.length === 0) {
+ const link = (
+ <a
+ href='#'
+ onClick={this.showEditChannelHeaderModal}
+ >
+ <FormattedMessage
+ id='navbar.click'
+ defaultMessage='Click here'
+ />
+ </a>
+ );
popoverContent = (
<Popover
bsStyle='info'
@@ -412,15 +462,14 @@ export default class Navbar extends React.Component {
id='header-popover'
>
<div>
- {'No channel header yet.'}
- <br/>
- <a
- href='#'
- onClick={this.showEditChannelHeaderModal}
- >
- {'Click here'}
- </a>
- {' to add one.'}
+ <FormattedMessage
+ id='navbar.noHeader'
+ defaultMessage='No channel header yet.{newline}{link} to add one.'
+ values={{
+ newline: (<br/>),
+ link: (link)
+ }}
+ />
</div>
</Popover>
);
diff --git a/web/react/components/post_attachment.jsx b/web/react/components/post_attachment.jsx
index 676bc91af..2eedfb7c1 100644
--- a/web/react/components/post_attachment.jsx
+++ b/web/react/components/post_attachment.jsx
@@ -3,7 +3,20 @@
import * as TextFormatting from '../utils/text_formatting.jsx';
-export default class PostAttachment extends React.Component {
+import {intlShape, injectIntl, defineMessages} from 'mm-intl';
+
+const holders = defineMessages({
+ collapse: {
+ id: 'post_attachment.collapse',
+ defaultMessage: '▲ collapse text'
+ },
+ more: {
+ id: 'post_attachment.more',
+ defaultMessage: '▼ read more'
+ }
+});
+
+class PostAttachment extends React.Component {
constructor(props) {
super(props);
@@ -28,7 +41,7 @@ export default class PostAttachment extends React.Component {
getInitState() {
const shouldCollapse = this.shouldCollapse();
const text = TextFormatting.formatText(this.props.attachment.text || '');
- const uncollapsedText = text + (shouldCollapse ? '<a class="attachment-link-more" href="#">▲ collapse text</a>' : '');
+ const uncollapsedText = text + (shouldCollapse ? `<a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.collapse)}</a>` : '');
const collapsedText = shouldCollapse ? this.getCollapsedText() : text;
return {
@@ -62,7 +75,7 @@ export default class PostAttachment extends React.Component {
text = text.substr(0, 700);
}
- return TextFormatting.formatText(text) + '<a class="attachment-link-more" href="#">▼ read more</a>';
+ return TextFormatting.formatText(text) + `<a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.more)}</a>`;
}
getFieldsTable() {
@@ -292,5 +305,8 @@ export default class PostAttachment extends React.Component {
}
PostAttachment.propTypes = {
+ intl: intlShape.isRequired,
attachment: React.PropTypes.object.isRequired
};
+
+export default injectIntl(PostAttachment);
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index b1657f0eb..d71ac6ec7 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -14,7 +14,20 @@ import YoutubeVideo from './youtube_video.jsx';
import providers from './providers.json';
-export default class PostBody extends React.Component {
+import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl';
+
+const holders = defineMessages({
+ plusOne: {
+ id: 'post_body.plusOne',
+ defaultMessage: ' plus 1 other file'
+ },
+ plusMore: {
+ id: 'post_body.plusMore',
+ defaultMessage: ' plus {count} other files'
+ }
+});
+
+class PostBody extends React.Component {
constructor(props) {
super(props);
@@ -31,7 +44,6 @@ export default class PostBody extends React.Component {
this.state = {
links: linkData.links,
- message: linkData.text,
post: this.props.post,
hasUserProfiles: profiles && Object.keys(profiles).length > 1
};
@@ -93,7 +105,9 @@ export default class PostBody extends React.Component {
if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) {
this.embed = this.createEmbed(linkData.links[0]);
}
- this.setState({links: linkData.links, message: linkData.text});
+ this.setState({
+ links: linkData.links
+ });
}
createEmbed(link) {
@@ -187,6 +201,7 @@ export default class PostBody extends React.Component {
}
render() {
+ const {formatMessage} = this.props.intl;
const post = this.props.post;
const filenames = this.props.post.filenames;
const parentPost = this.props.parentPost;
@@ -208,10 +223,12 @@ export default class PostBody extends React.Component {
username = parentPost.props.override_username;
}
- if (username.slice(-1) === 's') {
- apostrophe = '\'';
- } else {
- apostrophe = '\'s';
+ if (global.window.mm_locale === 'en') {
+ if (username.slice(-1) === 's') {
+ apostrophe = '\'';
+ } else {
+ apostrophe = '\'s';
+ }
}
name = (
<a
@@ -230,16 +247,23 @@ export default class PostBody extends React.Component {
message = parentPost.filenames[0].split('/').pop();
if (parentPost.filenames.length === 2) {
- message += ' plus 1 other file';
+ message += formatMessage(holders.plusOne);
} else if (parentPost.filenames.length > 2) {
- message += ` plus ${parentPost.filenames.length - 1} other files`;
+ message += formatMessage(holders.plusMore, {count: (parentPost.filenames.length - 1)});
}
}
comment = (
<div className='post__link'>
<span>
- {'Commented on '}{name}{apostrophe}{' message: '}
+ <FormattedMessage
+ id='post_body.commentedOn'
+ defaultMessage='Commented on {name}{apostrophe} message: '
+ values={{
+ name: (name),
+ apostrophe: apostrophe
+ }}
+ />
<a
className='theme'
onClick={this.props.handleCommentClick}
@@ -260,7 +284,10 @@ export default class PostBody extends React.Component {
href='#'
onClick={this.props.retryPost}
>
- {'Retry'}
+ <FormattedMessage
+ id='post_body.retry'
+ defaultMessage='Retry'
+ />
</a>
);
} else if (post.state === Constants.POST_LOADING) {
@@ -284,6 +311,23 @@ export default class PostBody extends React.Component {
);
}
+ let message;
+ if (this.props.post.state === Constants.POST_DELETED) {
+ message = (
+ <FormattedMessage
+ id='post_body.deleted'
+ defaultMessage='(message deleted)'
+ />
+ );
+ } else {
+ message = (
+ <span
+ onClick={TextFormatting.handleClick}
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message)}}
+ />
+ );
+ }
+
return (
<div>
{comment}
@@ -294,11 +338,7 @@ export default class PostBody extends React.Component {
className={postClass}
>
{loading}
- <span
- ref='message_span'
- onClick={TextFormatting.handleClick}
- dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}}
- />
+ {message}
</div>
<PostBodyAdditionalContent
post={this.state.post}
@@ -313,8 +353,11 @@ export default class PostBody extends React.Component {
}
PostBody.propTypes = {
+ intl: intlShape.isRequired,
post: React.PropTypes.object.isRequired,
parentPost: React.PropTypes.object,
retryPost: React.PropTypes.func.isRequired,
handleCommentClick: React.PropTypes.func.isRequired
};
+
+export default injectIntl(PostBody);
diff --git a/web/react/components/post_focus_view.jsx b/web/react/components/post_focus_view.jsx
index adcd78839..b9b6acd5f 100644
--- a/web/react/components/post_focus_view.jsx
+++ b/web/react/components/post_focus_view.jsx
@@ -7,6 +7,8 @@ import PostStore from '../stores/post_store.jsx';
import ChannelStore from '../stores/channel_store.jsx';
import * as EventHelpers from '../dispatcher/event_helpers.jsx';
+import {FormattedMessage} from 'mm-intl';
+
export default class PostFocusView extends React.Component {
constructor(props) {
super(props);
@@ -73,7 +75,12 @@ export default class PostFocusView extends React.Component {
getIntroMessage() {
return (
<div className='channel-intro'>
- <h4 className='channel-intro__title'>{'Beginning of Channel Archives'}</h4>
+ <h4 className='channel-intro__title'>
+ <FormattedMessage
+ id='post_focus_view.beginning'
+ defaultMessage='Beginning of Channel Archives'
+ />
+ </h4>
</div>
);
}
diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx
index 0fb9d7f4a..b1bc8ca14 100644
--- a/web/react/components/post_info.jsx
+++ b/web/react/components/post_info.jsx
@@ -9,6 +9,8 @@ import * as EventHelpers from '../dispatcher/event_helpers.jsx';
import Constants from '../utils/constants.jsx';
+import {FormattedMessage} from 'mm-intl';
+
const Overlay = ReactBootstrap.Overlay;
const Popover = ReactBootstrap.Popover;
@@ -21,13 +23,14 @@ export default class PostInfo extends React.Component {
};
this.handlePermalinkCopy = this.handlePermalinkCopy.bind(this);
+ this.removePost = this.removePost.bind(this);
}
createDropdown() {
var post = this.props.post;
var isOwner = UserStore.getCurrentId() === post.user_id;
var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles);
- if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) {
+ if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || Utils.isPostEphemeral(post)) {
return '';
}
@@ -53,7 +56,10 @@ export default class PostInfo extends React.Component {
href='#'
onClick={this.props.handleCommentClick}
>
- {'Reply'}
+ <FormattedMessage
+ id='post_info.reply'
+ defaultMessage='Reply'
+ />
</a>
</li>
);
@@ -68,7 +74,10 @@ export default class PostInfo extends React.Component {
href='#'
onClick={(e) => this.setState({target: e.target, show: !this.state.show})}
>
- {'Permalink'}
+ <FormattedMessage
+ id='post_info.permalink'
+ defaultMessage='Permalink'
+ />
</a>
</li>
);
@@ -84,7 +93,10 @@ export default class PostInfo extends React.Component {
role='menuitem'
onClick={() => EventHelpers.showDeletePostModal(post, dataComments)}
>
- {'Delete'}
+ <FormattedMessage
+ id='post_info.del'
+ defaultMessage='Delete'
+ />
</a>
</li>
);
@@ -108,7 +120,10 @@ export default class PostInfo extends React.Component {
data-channelid={post.channel_id}
data-comments={dataComments}
>
- {'Edit'}
+ <FormattedMessage
+ id='post_info.edit'
+ defaultMessage='Edit'
+ />
</a>
</li>
);
@@ -152,6 +167,25 @@ export default class PostInfo extends React.Component {
this.setState({copiedLink: false});
}
}
+ removePost() {
+ EventHelpers.emitRemovePost(this.props.post);
+ }
+ createRemovePostButton(post) {
+ if (!Utils.isPostEphemeral(post)) {
+ return null;
+ }
+
+ return (
+ <a
+ href='#'
+ className='post__remove theme'
+ type='button'
+ onClick={this.removePost}
+ >
+ {'×'}
+ </a>
+ );
+ }
render() {
var post = this.props.post;
var comments = '';
@@ -164,7 +198,7 @@ export default class PostInfo extends React.Component {
commentCountText = '';
}
- if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) {
+ if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) {
comments = (
<a
href='#'
@@ -183,7 +217,15 @@ export default class PostInfo extends React.Component {
var dropdown = this.createDropdown();
const permalink = TeamStore.getCurrentTeamUrl() + '/pl/' + post.id;
- const copyButtonText = this.state.copiedLink ? (<div>{'Copy '}<i className='fa fa-check'/></div>) : 'Copy';
+ const copyButtonText = this.state.copiedLink ? (
+ <div>
+ <FormattedMessage
+ id='post_info.copy'
+ defaultMessage='Copy '
+ />
+ <i className='fa fa-check'/></div>
+ ) : (<FormattedMessage id='post_info.copy' />);
+
const permalinkOverlay = (
<Popover
id='permalink-overlay'
@@ -242,6 +284,7 @@ export default class PostInfo extends React.Component {
>
{permalinkOverlay}
</Overlay>
+ {this.createRemovePostButton(post)}
</li>
</ul>
);
diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx
index 856403af5..f108ace2e 100644
--- a/web/react/components/posts_view.jsx
+++ b/web/react/components/posts_view.jsx
@@ -8,6 +8,9 @@ import * as Utils from '../utils/utils.jsx';
import Post from './post.jsx';
import Constants from '../utils/constants.jsx';
import DelayedAction from '../utils/delayed_action.jsx';
+
+import {FormattedDate, FormattedMessage} from 'mm-intl';
+
const Preferences = Constants.Preferences;
export default class PostsView extends React.Component {
@@ -250,7 +253,15 @@ export default class PostsView extends React.Component {
className='date-separator'
>
<hr className='separator__hr' />
- <div className='separator__text'>{currentPostDay.toDateString()}</div>
+ <div className='separator__text'>
+ <FormattedDate
+ value={currentPostDay}
+ weekday='short'
+ month='short'
+ day='2-digit'
+ year='numeric'
+ />
+ </div>
</div>
);
}
@@ -276,7 +287,12 @@ export default class PostsView extends React.Component {
<hr
className='separator__hr'
/>
- <div className='separator__text'>{'New Messages'}</div>
+ <div className='separator__text'>
+ <FormattedMessage
+ id='posts_view.newMsg'
+ defaultMessage='New Messages'
+ />
+ </div>
</div>
);
}
@@ -420,7 +436,10 @@ export default class PostsView extends React.Component {
href='#'
onClick={this.loadMorePostsTop}
>
- {'Load more messages'}
+ <FormattedMessage
+ id='posts_view.loadMore'
+ defaultMessage='Load more messages'
+ />
</a>
);
} else {
@@ -436,7 +455,7 @@ export default class PostsView extends React.Component {
href='#'
onClick={this.loadMorePostsBottom}
>
- {'Load more messages'}
+ <FormattedMessage id='posts_view.loadMore' />
</a>
);
} else {
diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx
index 0ad091d5b..544ba920a 100644
--- a/web/react/components/search_results_item.jsx
+++ b/web/react/components/search_results_item.jsx
@@ -59,64 +59,74 @@ export default class SearchResultsItem extends React.Component {
};
return (
- <div
- className='search-item-container post'
- >
- <div className='search-channel__name'>{channelName}</div>
- <div className='post__content'>
- <div className='post__img'>
- <img
- src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()}
- height='36'
- width='36'
+ <div className='search-item__container'>
+ <div className='date-separator'>
+ <hr className='separator__hr' />
+ <div className='separator__text'>
+ <FormattedDate
+ value={this.props.post.create_at}
+ day='numeric'
+ month='long'
+ year='numeric'
/>
</div>
- <div>
- <ul className='post__header'>
- <li className='col__name'><strong><UserProfile userId={this.props.post.user_id} /></strong></li>
- <li className='col'>
- <time className='search-item-time'>
- <FormattedDate
- value={this.props.post.create_at}
- day='numeric'
- month='long'
- year='numeric'
- hour12={true}
- hour='2-digit'
- minute='2-digit'
- />
- </time>
- </li>
- <li>
- <a
- href='#'
- className='search-item__jump'
- onClick={this.handleClick}
- >
- <FormattedMessage
- id='search_item.jump'
- defaultMessage='Jump'
- />
- </a>
- </li>
- <li>
- <a
- href='#'
- className='comment-icon__container search-item__comment'
- onClick={this.handleFocusRHSClick}
- >
- <span
- className='comment-icon'
- dangerouslySetInnerHTML={{__html: Constants.REPLY_ICON}}
- />
- </a>
- </li>
- </ul>
- <div className='search-item-snippet'>
- <span
- dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}}
+ </div>
+ <div
+ className='post'
+ >
+ <div className='search-channel__name'>{channelName}</div>
+ <div className='post__content'>
+ <div className='post__img'>
+ <img
+ src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()}
+ height='36'
+ width='36'
/>
</div>
+ <div>
+ <ul className='post__header'>
+ <li className='col__name'><strong><UserProfile userId={this.props.post.user_id} /></strong></li>
+ <li className='col'>
+ <time className='search-item-time'>
+ <FormattedDate
+ value={this.props.post.create_at}
+ hour12={true}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ </time>
+ </li>
+ <li>
+ <a
+ href='#'
+ className='search-item__jump'
+ onClick={this.handleClick}
+ >
+ <FormattedMessage
+ id='search_item.jump'
+ defaultMessage='Jump'
+ />
+ </a>
+ </li>
+ <li>
+ <a
+ href='#'
+ className='comment-icon__container search-item__comment'
+ onClick={this.handleFocusRHSClick}
+ >
+ <span
+ className='comment-icon'
+ dangerouslySetInnerHTML={{__html: Constants.REPLY_ICON}}
+ />
+ </a>
+ </li>
+ </ul>
+ <div className='search-item-snippet'>
+ <span
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}}
+ />
+ </div>
+ </div>
</div>
</div>
</div>
diff --git a/web/react/components/suggestion/command_provider.jsx b/web/react/components/suggestion/command_provider.jsx
index 91d556bb9..09c9b9982 100644
--- a/web/react/components/suggestion/command_provider.jsx
+++ b/web/react/components/suggestion/command_provider.jsx
@@ -2,7 +2,6 @@
// See License.txt for license information.
import * as AsyncClient from '../../utils/async_client.jsx';
-import SuggestionStore from '../../stores/suggestion_store.jsx';
class CommandSuggestion extends React.Component {
render() {
@@ -38,8 +37,6 @@ CommandSuggestion.propTypes = {
export default class CommandProvider {
handlePretextChanged(suggestionId, pretext) {
if (pretext.startsWith('/')) {
- SuggestionStore.setMatchedPretext(suggestionId, pretext);
-
AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion);
}
}
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index 00e5ace98..ec299087d 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -20,6 +20,7 @@ export default class Textbox extends React.Component {
constructor(props) {
super(props);
+ this.focus = this.focus.bind(this);
this.getStateFromStores = this.getStateFromStores.bind(this);
this.onRecievedError = this.onRecievedError.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
@@ -81,6 +82,10 @@ export default class Textbox extends React.Component {
}
}
+ focus() {
+ this.refs.message.getTextbox().focus();
+ }
+
resize() {
const textbox = this.refs.message.getTextbox();
const $textbox = $(textbox);
@@ -90,8 +95,6 @@ export default class Textbox extends React.Component {
const borders = parseInt($textbox.css('border-bottom-width'), 10) + parseInt($textbox.css('border-top-width'), 10);
const maxHeight = parseInt($textbox.css('max-height'), 10) - borders;
- const prevHeight = $textbox.height();
-
// set the height to auto and remove the scrollbar so we can get the actual size of the contents
$textbox.css('height', 'auto').css('overflow-y', 'hidden');
@@ -116,10 +119,6 @@ export default class Textbox extends React.Component {
if (this.state.preview) {
$(ReactDOM.findDOMNode(this.refs.preview)).height(height + borders);
}
-
- if (height !== prevHeight && this.props.onHeightChange) {
- this.props.onHeightChange();
- }
}
showPreview(e) {
@@ -211,7 +210,6 @@ Textbox.propTypes = {
messageText: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func.isRequired,
onKeyPress: React.PropTypes.func.isRequired,
- onHeightChange: React.PropTypes.func,
createMessage: React.PropTypes.string.isRequired,
onKeyDown: React.PropTypes.func,
supportsCommands: React.PropTypes.bool.isRequired
diff --git a/web/react/components/time_since.jsx b/web/react/components/time_since.jsx
index 0b549b1e6..1560d2469 100644
--- a/web/react/components/time_since.jsx
+++ b/web/react/components/time_since.jsx
@@ -4,6 +4,8 @@
import Constants from '../utils/constants.jsx';
import * as Utils from '../utils/utils.jsx';
+import {FormattedRelative, FormattedDate} from 'mm-intl';
+
var Tooltip = ReactBootstrap.Tooltip;
var OverlayTrigger = ReactBootstrap.OverlayTrigger;
@@ -20,20 +22,25 @@ export default class TimeSince extends React.Component {
clearInterval(this.intervalId);
}
render() {
- const displayDate = Utils.displayDate(this.props.eventTime);
- const displayTime = Utils.displayTime(this.props.eventTime);
-
if (this.props.sameUser) {
return (
<time className='post__time'>
- {Utils.displayTime(this.props.eventTime)}
+ {Utils.displayTimeFormatted(this.props.eventTime)}
</time>
);
}
const tooltip = (
<Tooltip id={'time-since-tooltip-' + this.props.eventTime}>
- {displayDate + ' at ' + displayTime}
+ <FormattedDate
+ value={this.props.eventTime}
+ month='long'
+ day='numeric'
+ year='numeric'
+ hour12={true}
+ hour='numeric'
+ minute='2-digit'
+ />
</Tooltip>
);
@@ -44,7 +51,7 @@ export default class TimeSince extends React.Component {
overlay={tooltip}
>
<time className='post__time'>
- {Utils.displayDateTime(this.props.eventTime)}
+ <FormattedRelative value={this.props.eventTime} />
</time>
</OverlayTrigger>
);
diff --git a/web/react/components/user_settings/manage_command_hooks.jsx b/web/react/components/user_settings/manage_command_hooks.jsx
new file mode 100644
index 000000000..b2fc0a4e1
--- /dev/null
+++ b/web/react/components/user_settings/manage_command_hooks.jsx
@@ -0,0 +1,673 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import LoadingScreen from '../loading_screen.jsx';
+
+import * as Client from '../../utils/client.jsx';
+
+import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'mm-intl';
+
+const holders = defineMessages({
+ requestTypePost: {
+ id: 'user.settings.cmds.request_type_post',
+ defaultMessage: 'POST'
+ },
+ requestTypeGet: {
+ id: 'user.settings.cmds.request_type_get',
+ defaultMessage: 'GET'
+ },
+ addDisplayNamePlaceholder: {
+ id: 'user.settings.cmds.add_display_name.placeholder',
+ defaultMessage: 'Display Name'
+ },
+ addUsernamePlaceholder: {
+ id: 'user.settings.cmds.add_username.placeholder',
+ defaultMessage: 'Username'
+ },
+ addTriggerPlaceholder: {
+ id: 'user.settings.cmds.add_trigger.placeholder',
+ defaultMessage: 'Command trigger e.g. "hello" not including the slash'
+ },
+ addAutoCompleteDescPlaceholder: {
+ id: 'user.settings.cmds.auto_complete_desc.placeholder',
+ defaultMessage: 'A short description of what this commands does.'
+ },
+ addAutoCompleteHintPlaceholder: {
+ id: 'user.settings.cmds.auto_complete_hint.placeholder',
+ defaultMessage: '[zipcode]'
+ },
+ adUrlPlaceholder: {
+ id: 'user.settings.cmds.url.placeholder',
+ defaultMessage: 'Must start with http:// or https://'
+ },
+ autocompleteYes: {
+ id: 'user.settings.cmds.auto_complete.yes',
+ defaultMessage: 'yes'
+ },
+ autocompleteNo: {
+ id: 'user.settings.cmds.auto_complete.no',
+ defaultMessage: 'no'
+ }
+});
+
+export default class ManageCommandCmds extends React.Component {
+ constructor() {
+ super();
+
+ this.getCmds = this.getCmds.bind(this);
+ this.addNewCmd = this.addNewCmd.bind(this);
+ this.emptyCmd = this.emptyCmd.bind(this);
+ this.updateTrigger = this.updateTrigger.bind(this);
+ this.updateURL = this.updateURL.bind(this);
+ this.updateMethod = this.updateMethod.bind(this);
+ this.updateUsername = this.updateUsername.bind(this);
+ this.updateIconURL = this.updateIconURL.bind(this);
+ this.updateDisplayName = this.updateDisplayName.bind(this);
+ this.updateAutoComplete = this.updateAutoComplete.bind(this);
+ this.updateAutoCompleteDesc = this.updateAutoCompleteDesc.bind(this);
+ this.updateAutoCompleteHint = this.updateAutoCompleteHint.bind(this);
+
+ this.state = {cmds: [], cmd: this.emptyCmd(), getCmdsComplete: false};
+ }
+
+ static propTypes() {
+ return {
+ intl: intlShape.isRequired
+ };
+ }
+
+ emptyCmd() {
+ var cmd = {};
+ cmd.url = '';
+ cmd.trigger = '';
+ cmd.method = 'P';
+ cmd.username = '';
+ cmd.icon_url = '';
+ cmd.auto_complete = false;
+ cmd.auto_complete_desc = '';
+ cmd.auto_complete_hint = '';
+ cmd.display_name = '';
+ return cmd;
+ }
+
+ componentDidMount() {
+ this.getCmds();
+ }
+
+ addNewCmd(e) {
+ e.preventDefault();
+
+ if (this.state.cmd.trigger === '' || this.state.cmd.url === '') {
+ return;
+ }
+
+ var cmd = this.state.cmd;
+ if (cmd.trigger.length !== 0) {
+ cmd.trigger = cmd.trigger.trim();
+ }
+ cmd.url = cmd.url.trim();
+
+ Client.addCommand(
+ cmd,
+ (data) => {
+ let cmds = Object.assign([], this.state.cmds);
+ if (!cmds) {
+ cmds = [];
+ }
+ cmds.push(data);
+ this.setState({cmds, addError: null, cmd: this.emptyCmd()});
+ },
+ (err) => {
+ this.setState({addError: err.message});
+ }
+ );
+ }
+
+ removeCmd(id) {
+ const data = {};
+ data.id = id;
+
+ Client.deleteCommand(
+ data,
+ () => {
+ const cmds = this.state.cmds;
+ let index = -1;
+ for (let i = 0; i < cmds.length; i++) {
+ if (cmds[i].id === id) {
+ index = i;
+ break;
+ }
+ }
+
+ if (index !== -1) {
+ cmds.splice(index, 1);
+ }
+
+ this.setState({cmds});
+ },
+ (err) => {
+ this.setState({editError: err.message});
+ }
+ );
+ }
+
+ regenToken(id) {
+ const regenData = {};
+ regenData.id = id;
+
+ Client.regenCommandToken(
+ regenData,
+ (data) => {
+ const cmds = Object.assign([], this.state.cmds);
+ for (let i = 0; i < cmds.length; i++) {
+ if (cmds[i].id === id) {
+ cmds[i] = data;
+ break;
+ }
+ }
+
+ this.setState({cmds, editError: null});
+ },
+ (err) => {
+ this.setState({editError: err.message});
+ }
+ );
+ }
+
+ getCmds() {
+ Client.listTeamCommands(
+ (data) => {
+ if (data) {
+ this.setState({cmds: data, getCmdsComplete: true, editError: null});
+ }
+ },
+ (err) => {
+ this.setState({editError: err.message});
+ }
+ );
+ }
+
+ updateTrigger(e) {
+ var cmd = this.state.cmd;
+ cmd.trigger = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateURL(e) {
+ var cmd = this.state.cmd;
+ cmd.url = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateMethod(e) {
+ var cmd = this.state.cmd;
+ cmd.method = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateUsername(e) {
+ var cmd = this.state.cmd;
+ cmd.username = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateIconURL(e) {
+ var cmd = this.state.cmd;
+ cmd.icon_url = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateDisplayName(e) {
+ var cmd = this.state.cmd;
+ cmd.display_name = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateAutoComplete(e) {
+ var cmd = this.state.cmd;
+ cmd.auto_complete = e.target.checked;
+ this.setState(cmd);
+ }
+
+ updateAutoCompleteDesc(e) {
+ var cmd = this.state.cmd;
+ cmd.auto_complete_desc = e.target.value;
+ this.setState(cmd);
+ }
+
+ updateAutoCompleteHint(e) {
+ var cmd = this.state.cmd;
+ cmd.auto_complete_hint = e.target.value;
+ this.setState(cmd);
+ }
+
+ render() {
+ let addError;
+ if (this.state.addError) {
+ addError = <label className='has-error'>{this.state.addError}</label>;
+ }
+
+ let editError;
+ if (this.state.editError) {
+ addError = <label className='has-error'>{this.state.editError}</label>;
+ }
+
+ const cmds = [];
+ this.state.cmds.forEach((cmd) => {
+ let triggerDiv;
+ if (cmd.trigger && cmd.trigger.length !== 0) {
+ triggerDiv = (
+ <div className='padding-top'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.trigger'
+ defaultMessage='Trigger: '
+ />
+ </strong>{cmd.trigger}
+ </div>
+ );
+ }
+
+ cmds.push(
+ <div
+ key={cmd.id}
+ className='webcmd__item'
+ >
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.display_name'
+ defaultMessage='Display Name: '
+ />
+ </strong><span className='word-break--all'>{cmd.display_name}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.username'
+ defaultMessage='Username: '
+ />
+ </strong><span className='word-break--all'>{cmd.username}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.icon_url'
+ defaultMessage='Icon URL: '
+ />
+ </strong><span className='word-break--all'>{cmd.icon_url}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete'
+ defaultMessage='Auto Complete: '
+ />
+ </strong><span className='word-break--all'>{cmd.auto_complete ? this.props.intl.formatMessage(holders.autocompleteYes) : this.props.intl.formatMessage(holders.autocompleteNo)}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_desc'
+ defaultMessage='Auto Complete Description: '
+ />
+ </strong><span className='word-break--all'>{cmd.auto_complete_desc}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_hint'
+ defaultMessage='Auto Complete Hint: '
+ />
+ </strong><span className='word-break--all'>{cmd.auto_complete_hint}</span>
+ </div>
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.request_type'
+ defaultMessage='Request Type: '
+ />
+ </strong>
+ <span className='word-break--all'>
+ {
+ cmd.method === 'P' ?
+ <FormattedMessage
+ id='user.settings.cmds.request_type_post'
+ defaultMessage='POST'
+ /> :
+ <FormattedMessage
+ id='user.settings.cmds.request_type_get'
+ defaultMessage='GET'
+ />
+ }
+ </span>
+ </div>
+ <div className='padding-top x2 webcmd__url'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.url'
+ defaultMessage='URL: '
+ />
+ </strong><span className='word-break--all'>{cmd.url}</span>
+ </div>
+ {triggerDiv}
+ <div className='padding-top'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.token'
+ defaultMessage='Token: '
+ />
+ </strong>{cmd.token}
+ </div>
+ <div className='padding-top'>
+ <a
+ className='text-danger'
+ href='#'
+ onClick={this.regenToken.bind(this, cmd.id)}
+ >
+ <FormattedMessage
+ id='user.settings.cmds.regen'
+ defaultMessage='Regen Token'
+ />
+ </a>
+ <a
+ className='webcmd__remove'
+ href='#'
+ onClick={this.removeCmd.bind(this, cmd.id)}
+ >
+ <span aria-hidden='true'>{'×'}</span>
+ </a>
+ </div>
+ <div className='padding-top x2 divider-light'></div>
+ </div>
+ );
+ });
+
+ let displayCmds;
+ if (!this.state.getCmdsComplete) {
+ displayCmds = <LoadingScreen/>;
+ } else if (cmds.length > 0) {
+ displayCmds = cmds;
+ } else {
+ displayCmds = (
+ <div className='padding-top x2'>
+ <FormattedMessage
+ id='user.settings.cmds.none'
+ defaultMessage='None'
+ />
+ </div>
+ );
+ }
+
+ const existingCmds = (
+ <div className='webcmds__container'>
+ <label className='control-label padding-top x2'>
+ <FormattedMessage
+ id='user.settings.cmds.existing'
+ defaultMessage='Existing commands'
+ />
+ </label>
+ <div className='padding-top divider-light'></div>
+ <div className='webcmds__list'>
+ {displayCmds}
+ </div>
+ </div>
+ );
+
+ const disableButton = this.state.cmd.trigger === '' || this.state.cmd.url === '';
+
+ return (
+ <div key='addCommandCmd'>
+ <FormattedHTMLMessage
+ id='user.settings.cmds.add_desc'
+ defaultMessage='Create commands to send message events to an external integration. Please see <a href="http://mattermost.org/commands">http://mattermost.org/commands</a> to learn more.'
+ />
+ <div><label className='control-label padding-top x2'>
+ <FormattedMessage
+ id='user.settings.cmds.add_new'
+ defaultMessage='Add a new command'
+ />
+ </label></div>
+ <div className='padding-top divider-light'></div>
+ <div className='padding-top'>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.display_name'
+ defaultMessage='Display Name: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='displayName'
+ className='form-control'
+ value={this.state.cmd.display_name}
+ onChange={this.updateDisplayName}
+ placeholder={this.props.intl.formatMessage(holders.addDisplayNamePlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.cmd_display_name'
+ defaultMessage='Command display name.'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.username'
+ defaultMessage='Username: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='username'
+ className='form-control'
+ value={this.state.cmd.username}
+ onChange={this.updateUsername}
+ placeholder={this.props.intl.formatMessage(holders.addUsernamePlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.username_desc'
+ defaultMessage='The username to use when overriding the post.'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.icon_url'
+ defaultMessage='Icon URL: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='iconURL'
+ className='form-control'
+ value={this.state.cmd.icon_url}
+ onChange={this.updateIconURL}
+ placeholder='https://www.example.com/myicon.png'
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.icon_url_desc'
+ defaultMessage='URL to an icon'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.trigger'
+ defaultMessage='Trigger: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='trigger'
+ className='form-control'
+ value={this.state.cmd.trigger}
+ onChange={this.updateTrigger}
+ placeholder={this.props.intl.formatMessage(holders.addTriggerPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.trigger_desc'
+ defaultMessage='Word to trigger on'
+ />
+ {''}</div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete'
+ defaultMessage='Auto Complete: '
+ />
+ </label>
+ <div className='padding-top'>
+ <label>
+ <input
+ type='checkbox'
+ checked={this.state.cmd.auto_complete}
+ onChange={this.updateAutoComplete}
+ />
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_desc_desc'
+ defaultMessage='A short description of what this commands does'
+ />
+ </label>
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_help'
+ defaultMessage='Show this command in autocomplete list.'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_desc'
+ defaultMessage='Auto Complete Description: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='autoCompleteDesc'
+ className='form-control'
+ value={this.state.cmd.auto_complete_desc}
+ onChange={this.updateAutoCompleteDesc}
+ placeholder={this.props.intl.formatMessage(holders.addAutoCompleteDescPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_desc_desc'
+ defaultMessage='A short description of what this commands does'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_hint'
+ defaultMessage='Auto Complete Hint: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='autoCompleteHint'
+ className='form-control'
+ value={this.state.cmd.auto_complete_hint}
+ onChange={this.updateAutoCompleteHint}
+ placeholder={this.props.intl.formatMessage(holders.addAutoCompleteHintPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_hint_desc'
+ defaultMessage='List parameters to be passed to the command.'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.request_type'
+ defaultMessage='Request Type: '
+ />
+ </label>
+ <div className='padding-top'>
+ <select
+ ref='method'
+ className='form-control'
+ value={this.state.cmd.method}
+ onChange={this.updateMethod}
+ >
+ <option value='P'>
+ {this.props.intl.formatMessage(holders.requestTypePost)}
+ </option>
+ <option value='G'>
+ {this.props.intl.formatMessage(holders.requestTypeGet)}
+ </option>
+ </select>
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.request_type_desc'
+ defaultMessage='Command request type issued to the callback URL.'
+ />
+ </div>
+ </div>
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.url'
+ defaultMessage='URL: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='URL'
+ className='form-control'
+ value={this.state.cmd.url}
+ rows={1}
+ onChange={this.updateURL}
+ placeholder={this.props.intl.formatMessage(holders.adUrlPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.url_desc'
+ defaultMessage='URL that will receive the HTTP POST or GET event'
+ />
+ </div>
+ {addError}
+ </div>
+ <div className='padding-top padding-bottom'>
+ <a
+ className={'btn btn-sm btn-primary'}
+ href='#'
+ disabled={disableButton}
+ onClick={this.addNewCmd}
+ >
+ <FormattedMessage
+ id='user.settings.cmds.add'
+ defaultMessage='Add'
+ />
+ </a>
+ </div>
+ </div>
+ {existingCmds}
+ {editError}
+ </div>
+ );
+ }
+}
+
+export default injectIntl(ManageCommandCmds);
diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx
index 3b2a2065b..776bde442 100644
--- a/web/react/components/user_settings/user_settings_display.jsx
+++ b/web/react/components/user_settings/user_settings_display.jsx
@@ -297,7 +297,7 @@ class UserSettingsDisplay extends React.Component {
if (this.state.nameFormat === 'username') {
describe = formatMessage(holders.showUsername);
} else if (this.state.nameFormat === 'full_name') {
- describe = formatMessage(holders.showFullName);
+ describe = formatMessage(holders.showFullname);
} else {
describe = formatMessage(holders.showNickname);
}
diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx
index abd04a301..1a9edab03 100644
--- a/web/react/components/user_settings/user_settings_integrations.jsx
+++ b/web/react/components/user_settings/user_settings_integrations.jsx
@@ -5,6 +5,7 @@ import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
import ManageIncomingHooks from './manage_incoming_hooks.jsx';
import ManageOutgoingHooks from './manage_outgoing_hooks.jsx';
+import ManageCommandHooks from './manage_command_hooks.jsx';
import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl';
@@ -24,6 +25,14 @@ const holders = defineMessages({
outDesc: {
id: 'user.settings.integrations.outWebhooksDescription',
defaultMessage: 'Manage your outgoing webhooks'
+ },
+ cmdName: {
+ id: 'user.settings.integrations.commands',
+ defaultMessage: 'Commands'
+ },
+ cmdDesc: {
+ id: 'user.settings.integrations.commandsDescription',
+ defaultMessage: 'Manage your commands'
}
});
@@ -41,6 +50,7 @@ class UserSettingsIntegrationsTab extends React.Component {
render() {
let incomingHooksSection;
let outgoingHooksSection;
+ let commandHooksSection;
var inputs = [];
const {formatMessage} = this.props.intl;
@@ -106,6 +116,37 @@ class UserSettingsIntegrationsTab extends React.Component {
}
}
+ if (global.window.mm_config.EnableCommands === 'true') {
+ if (this.props.activeSection === 'command-hooks') {
+ inputs.push(
+ <ManageCommandHooks key='command-hook-ui' />
+ );
+
+ commandHooksSection = (
+ <SettingItemMax
+ title={formatMessage(holders.cmdName)}
+ width='medium'
+ inputs={inputs}
+ updateSection={(e) => {
+ this.updateSection('');
+ e.preventDefault();
+ }}
+ />
+ );
+ } else {
+ commandHooksSection = (
+ <SettingItemMin
+ title={formatMessage(holders.cmdName)}
+ width='medium'
+ describe={formatMessage(holders.cmdDesc)}
+ updateSection={() => {
+ this.updateSection('command-hooks');
+ }}
+ />
+ );
+ }
+ }
+
return (
<div>
<div className='modal-header'>
@@ -144,6 +185,8 @@ class UserSettingsIntegrationsTab extends React.Component {
<div className='divider-light'/>
{outgoingHooksSection}
<div className='divider-dark'/>
+ {commandHooksSection}
+ <div className='divider-dark'/>
</div>
</div>
);
diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx
index 91a03eb70..786e53f10 100644
--- a/web/react/components/user_settings/user_settings_notifications.jsx
+++ b/web/react/components/user_settings/user_settings_notifications.jsx
@@ -294,7 +294,7 @@ class NotificationsTab extends React.Component {
<span>
<FormattedMessage
id='user.settings.notifications.info'
- defaultMessage='Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
+ defaultMessage='Desktop notifications are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
/>
</span>
);
@@ -395,8 +395,8 @@ class NotificationsTab extends React.Component {
const extraInfo = (
<span>
<FormattedMessage
- id='user.settings.notifications.info'
- defaultMessage='Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
+ id='user.settings.notifications.sounds_info'
+ defaultMessage='Desktop notifications sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
/>
</span>
);
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index d11f8a21c..90885e495 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -9,10 +9,20 @@ import Constants from '../utils/constants.jsx';
import FileInfoPreview from './file_info_preview.jsx';
import FileStore from '../stores/file_store.jsx';
import ViewImagePopoverBar from './view_image_popover_bar.jsx';
+
+import {intlShape, injectIntl, defineMessages} from 'mm-intl';
+
const Modal = ReactBootstrap.Modal;
const KeyCodes = Constants.KeyCodes;
-export default class ViewImageModal extends React.Component {
+const holders = defineMessages({
+ loading: {
+ id: 'view_image.loading',
+ defaultMessage: 'Loading '
+ }
+});
+
+class ViewImageModal extends React.Component {
constructor(props) {
super(props);
@@ -235,6 +245,7 @@ export default class ViewImageModal extends React.Component {
fileUrl={fileUrl}
fileInfo={this.state.fileInfo}
maxHeight={this.state.imgHeight}
+ formatMessage={this.props.intl.formatMessage}
/>
);
} else {
@@ -243,6 +254,7 @@ export default class ViewImageModal extends React.Component {
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo}
+ formatMessage={this.props.intl.formatMessage}
/>
);
}
@@ -250,7 +262,12 @@ export default class ViewImageModal extends React.Component {
// display a progress indicator when the preview for an image is still loading
const progress = Math.floor(this.state.progress[this.state.imgId]);
- content = <LoadingImagePreview progress={progress} />;
+ content = (
+ <LoadingImagePreview
+ progress={progress}
+ loading={this.props.intl.formatMessage(holders.loading)}
+ />
+ );
}
let leftArrow = null;
@@ -335,6 +352,7 @@ ViewImageModal.defaultProps = {
startId: 0
};
ViewImageModal.propTypes = {
+ intl: intlShape.isRequired,
show: React.PropTypes.bool.isRequired,
onModalDismissed: React.PropTypes.func.isRequired,
filenames: React.PropTypes.array,
@@ -344,12 +362,12 @@ ViewImageModal.propTypes = {
startId: React.PropTypes.number
};
-function LoadingImagePreview({progress}) {
+function LoadingImagePreview({progress, loading}) {
let progressView = null;
if (progress) {
progressView = (
<span className='loader-percent'>
- {'Loading ' + progress + '%'}
+ {loading + progress + '%'}
</span>
);
}
@@ -386,3 +404,5 @@ function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
</a>
);
}
+
+export default injectIntl(ViewImageModal); \ No newline at end of file
diff --git a/web/react/components/view_image_popover_bar.jsx b/web/react/components/view_image_popover_bar.jsx
index 1287f4fba..97671b845 100644
--- a/web/react/components/view_image_popover_bar.jsx
+++ b/web/react/components/view_image_popover_bar.jsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import {FormattedMessage} from 'mm-intl';
+
export default class ViewImagePopoverBar extends React.Component {
constructor(props) {
super(props);
@@ -16,7 +18,10 @@ export default class ViewImagePopoverBar extends React.Component {
data-title='Public Image'
onClick={this.props.getPublicLink}
>
- {'Get Public Link'}
+ <FormattedMessage
+ id='view_image_popover.publicLink'
+ defaultMessage='Get Public Link'
+ />
</a>
<span className='text'>{' | '}</span>
</div>
@@ -33,7 +38,16 @@ export default class ViewImagePopoverBar extends React.Component {
ref='imageFooter'
className={footerClass}
>
- <span className='pull-left text'>{'File ' + (this.props.fileId + 1) + ' of ' + this.props.totalFiles}</span>
+ <span className='pull-left text'>
+ <FormattedMessage
+ id='view_image_popover.file'
+ defaultMessage='File {count} of {total}'
+ values={{
+ count: (this.props.fileId + 1),
+ total: this.props.totalFiles
+ }}
+ />
+ </span>
<div className='image-links'>
{publicLink}
<a
@@ -41,7 +55,10 @@ export default class ViewImagePopoverBar extends React.Component {
download={this.props.filename}
className='text'
>
- {'Download'}
+ <FormattedMessage
+ id='view_image_popover.download'
+ defaultMessage='Download'
+ />
</a>
</div>
</div>