Commit 145ff16b authored by Harrison Healey's avatar Harrison Healey Committed by Joram Wilander
Browse files

PLT-3145 Custom Emojis (#3381)

* Reorganized Backstage code to use a view controller and separated it from integrations code

* Renamed InstalledIntegrations component to BackstageList

* Added EmojiList page

* Added AddEmoji page

* Added custom emoji to autocomplete and text formatter

* Moved system emoji to EmojiStore

* Stopped trying to get emoji before logging in

* Rerender posts when emojis change

* Fixed submit handler on backstage pages to properly support enter

* Removed debugging code

* Updated javascript driver

* Fixed unit tests

* Fixed backstage routes

* Added clientside validation to prevent users from creating an emoji with the same name as a system one

* Fixed AddEmoji page to properly redirect when an emoji is created successfully

* Fixed updating emoji list when an emoji is deleted

* Added type prop to BackstageList to properly support using a table for the list

* Added help text to EmojiList

* Fixed backstage on smaller screen sizes

* Disable custom emoji by default

* Improved restrictions on creating emojis

* Fixed non-admin users seeing the option to delete each other's emojis

* Fixing gofmt

* Fixed emoji unit tests

* Fixed trying to get emoji from the server when it's disabled
parent efb60fb1
......@@ -27,7 +27,10 @@ export default class CustomEmojiSettings extends AdminSettings {
getConfigFromState(config) {
config.ServiceSettings.EnableCustomEmoji = this.state.enableCustomEmoji;
config.ServiceSettings.RestrictCustomEmojiCreation = this.state.restrictCustomEmojiCreation;
if (global.window.mm_license.IsLicensed === 'true') {
config.ServiceSettings.RestrictCustomEmojiCreation = this.state.restrictCustomEmojiCreation;
}
return config;
}
......@@ -44,29 +47,14 @@ export default class CustomEmojiSettings extends AdminSettings {
}
renderSettings() {
return (
<SettingsGroup>
<BooleanSetting
id='enableCustomEmoji'
label={
<FormattedMessage
id='admin.customization.enableCustomEmojiTitle'
defaultMessage='Enable Custom Emoji:'
/>
}
helpText={
<FormattedMessage
id='admin.customization.enableCustomEmojiDesc'
defaultMessage='Enable users to create custom emoji for use in chat messages.'
/>
}
value={this.state.enableCustomEmoji}
onChange={this.handleChange}
/>
let restrictSetting = null;
if (global.window.mm_license.IsLicensed === 'true') {
restrictSetting = (
<DropdownSetting
id='restrictCustomEmojiCreation'
values={[
{value: 'all', text: Utils.localizeMessage('admin.customization.restrictCustomEmojiCreationAll', 'Allow everyone to create custom emoji')},
{value: 'admin', text: Utils.localizeMessage('admin.customization.restrictCustomEmojiCreationAdmin', 'Allow system and team admins to create custom emoji')},
{value: 'system_admin', text: Utils.localizeMessage('admin.customization.restrictCustomEmojiCreationSystemAdmin', 'Only allow system admins to create custom emoji')}
]}
label={
......@@ -85,6 +73,29 @@ export default class CustomEmojiSettings extends AdminSettings {
onChange={this.handleChange}
disabled={!this.state.enableCustomEmoji}
/>
);
}
return (
<SettingsGroup>
<BooleanSetting
id='enableCustomEmoji'
label={
<FormattedMessage
id='admin.customization.enableCustomEmojiTitle'
defaultMessage='Enable Custom Emoji:'
/>
}
helpText={
<FormattedMessage
id='admin.customization.enableCustomEmojiDesc'
defaultMessage='Enable users to create custom emoji for use in chat messages.'
/>
}
value={this.state.enableCustomEmoji}
onChange={this.handleChange}
/>
{restrictSetting}
</SettingsGroup>
);
}
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import TeamStore from 'stores/team_store.jsx';
import BackstageSidebar from './components/backstage_sidebar.jsx';
import BackstageNavbar from './components/backstage_navbar.jsx';
import ErrorBar from 'components/error_bar.jsx';
export default class BackstageController extends React.Component {
static get propTypes() {
return {
children: React.PropTypes.node.isRequired,
params: React.PropTypes.object.isRequired,
user: React.PropTypes.user.isRequired
};
}
constructor(props) {
super(props);
this.onTeamChange = this.onTeamChange.bind(this);
this.state = {
team: props.params.team ? TeamStore.getByName(props.params.team) : TeamStore.getCurrent()
};
}
componentDidMount() {
TeamStore.addChangeListener(this.onTeamChange);
}
componentWillUnmount() {
TeamStore.removeChangeListener(this.onTeamChange);
}
onTeamChange() {
this.state = {
team: this.props.params.team ? TeamStore.getByName(this.props.params.team) : TeamStore.getCurrent()
};
}
render() {
return (
<div className='backstage'>
<ErrorBar/>
<BackstageNavbar team={this.state.team}/>
<div className='backstage-body'>
<BackstageSidebar
team={this.state.team}
user={this.props.user}
/>
{
React.Children.map(this.props.children, (child) => {
if (!child) {
return child;
}
return React.cloneElement(child, {
team: this.state.team,
user: this.props.user
});
})
}
</div>
</div>
);
}
}
\ No newline at end of file
......@@ -59,6 +59,7 @@ export default class BackstageCategory extends React.Component {
to={link}
className='category-title'
activeClassName='category-title--active'
onlyActiveOnIndex={true}
>
<i className={'fa ' + icon}/>
<span className='category-title__text'>
......
......@@ -5,19 +5,22 @@ import React from 'react';
import * as Utils from 'utils/utils.jsx';
import {Link} from 'react-router/es6';
import {Link} from 'react-router';
import LoadingScreen from 'components/loading_screen.jsx';
export default class InstalledIntegrations extends React.Component {
static get propTypes() {
return {
children: React.PropTypes.node,
header: React.PropTypes.node.isRequired,
addLink: React.PropTypes.string.isRequired,
addText: React.PropTypes.node.isRequired,
emptyText: React.PropTypes.node.isRequired,
loading: React.PropTypes.bool.isRequired
};
export default class BackstageList extends React.Component {
static propTypes = {
children: React.PropTypes.node,
header: React.PropTypes.node.isRequired,
addLink: React.PropTypes.string,
addText: React.PropTypes.node,
emptyText: React.PropTypes.node,
loading: React.PropTypes.bool.isRequired,
searchPlaceholder: React.PropTypes.string
}
static defaultProps = {
searchPlaceholder: Utils.localizeMessage('backstage.search', 'Search')
}
constructor(props) {
......@@ -40,7 +43,6 @@ export default class InstalledIntegrations extends React.Component {
const filter = this.state.filter.toLowerCase();
let children;
if (this.props.loading) {
children = <LoadingScreen/>;
} else {
......@@ -48,53 +50,58 @@ export default class InstalledIntegrations extends React.Component {
return React.cloneElement(child, {filter});
});
if (children.length === 0) {
if (children.length === 0 && this.props.emptyText) {
children = (
<span className='backstage-list__item backstage-list_empty'>
<span className='backstage-list__item backstage-list__empty'>
{this.props.emptyText}
</span>
);
}
}
let addLink = null;
if (this.props.addLink && this.props.addText) {
addLink = (
<Link
className='add-link'
to={this.props.addLink}
>
<button
type='button'
className='btn btn-primary'
>
<span>
{this.props.addText}
</span>
</button>
</Link>
);
}
return (
<div className='backstage-content'>
<div className='installed-integrations'>
<div className='backstage-header'>
<h1>
{this.props.header}
</h1>
<Link
className='add-integrations-link'
to={this.props.addLink}
>
<button
type='button'
className='btn btn-primary'
>
<span>
{this.props.addText}
</span>
</button>
</Link>
</div>
<div className='backstage-filters'>
<div className='backstage-filter__search'>
<i className='fa fa-search'></i>
<input
type='search'
className='form-control'
placeholder={Utils.localizeMessage('installed_integrations.search', 'Search Integrations')}
value={this.state.filter}
onChange={this.updateFilter}
style={{flexGrow: 0, flexShrink: 0}}
/>
</div>
</div>
<div className='backstage-list'>
{children}
<div className='backstage-header'>
<h1>
{this.props.header}
</h1>
{addLink}
</div>
<div className='backstage-filters'>
<div className='backstage-filter__search'>
<i className='fa fa-search'></i>
<input
type='search'
className='form-control'
placeholder={this.props.searchPlaceholder}
value={this.state.filter}
onChange={this.updateFilter}
style={{flexGrow: 0, flexShrink: 0}}
/>
</div>
</div>
<div className='backstage-list'>
{children}
</div>
</div>
);
}
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import React from 'react';
import TeamStore from 'stores/team_store.jsx';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router/es6';
export default class BackstageNavbar extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
team: TeamStore.getCurrent()
static get propTypes() {
return {
team: React.propTypes.object.isRequired
};
}
componentDidMount() {
TeamStore.addChangeListener(this.handleChange);
$('body').addClass('backstage');
}
componentWillUnmount() {
TeamStore.removeChangeListener(this.handleChange);
$('body').removeClass('backstage');
}
handleChange() {
this.setState({
team: TeamStore.getCurrent()
});
}
render() {
if (!this.state.team) {
if (!this.props.team) {
return null;
}
return (
<div className='backstage-navbar row'>
<div className='backstage-navbar'>
<Link
className='backstage-navbar__back'
to={`/${this.state.team.name}/channels/town-square`}
to={`/${this.props.team.name}/channels/town-square`}
>
<i className='fa fa-angle-left'/>
<span>
......
......@@ -3,13 +3,51 @@
import React from 'react';
import * as Utils from 'utils/utils.jsx';
import TeamStore from 'stores/team_store.jsx';
import BackstageCategory from './backstage_category.jsx';
import BackstageSection from './backstage_section.jsx';
import {FormattedMessage} from 'react-intl';
export default class BackstageSidebar extends React.Component {
render() {
static get propTypes() {
return {
team: React.PropTypes.object.isRequired,
user: React.PropTypes.object.isRequired
};
}
renderCustomEmoji() {
if (window.mm_config.EnableCustomEmoji !== 'true') {
return null;
}
return (
<BackstageCategory
name='emoji'
parentLink={'/' + this.props.team.name}
icon='fa-smile-o'
title={
<FormattedMessage
id='backstage_sidebar.emoji'
defaultMessage='Custom Emoji'
/>
}
/>
);
}
renderIntegrations() {
if (window.mm_config.EnableIncomingWebhooks !== 'true' &&
window.mm_config.EnableOutgoingWebhooks !== 'true' &&
window.mm_config.EnableCommands !== 'true') {
return null;
}
if (window.mm_config.RestrictCustomEmojiCreation !== 'all' && !TeamStore.isTeamAdmin(this.props.user.id, this.props.team.id)) {
return null;
}
let incomingWebhooks = null;
if (window.mm_config.EnableIncomingWebhooks === 'true') {
incomingWebhooks = (
......@@ -55,24 +93,31 @@ export default class BackstageSidebar extends React.Component {
);
}
return (
<BackstageCategory
name='integrations'
parentLink={'/' + this.props.team.name}
icon='fa-link'
title={
<FormattedMessage
id='backstage_sidebar.integrations'
defaultMessage='Integrations'
/>
}
>
{incomingWebhooks}
{outgoingWebhooks}
{commands}
</BackstageCategory>
);
}
render() {
return (
<div className='backstage-sidebar'>
<ul>
<BackstageCategory
name='integrations'
parentLink={'/' + Utils.getTeamNameFromUrl() + '/settings'}
icon='fa-link'
title={
<FormattedMessage
id='backstage_sidebar.integrations'
defaultMessage='Integrations'
/>
}
>
{incomingWebhooks}
{outgoingWebhooks}
{commands}
</BackstageCategory>
{this.renderCustomEmoji()}
{this.renderIntegrations()}
</ul>
</div>
);
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as AsyncClient from 'utils/async_client.jsx';
import EmojiStore from 'stores/emoji_store.jsx';
import BackstageHeader from 'components/backstage/components/backstage_header.jsx';
import {FormattedMessage} from 'react-intl';
import FormError from 'components/form_error.jsx';
import {Link} from 'react-router';
import SpinnerButton from 'components/spinner_button.jsx';
export default class AddEmoji extends React.Component {
static propTypes = {
team: React.PropTypes.object.isRequired,
user: React.PropTypes.object.isRequired
}
static contextTypes = {
router: React.PropTypes.object.isRequired
}
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.updateName = this.updateName.bind(this);
this.updateImage = this.updateImage.bind(this);
this.state = {
name: '',
image: null,
imageUrl: '',
saving: false,
error: null
};
}
handleSubmit(e) {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
error: null
});
const emoji = {
creator_id: this.props.user.id,
name: this.state.name.trim().toLowerCase()
};
if (!emoji.name) {
this.setState({
saving: false,
error: (
<FormattedMessage
id='add_emoji.nameRequired'
defaultMessage='A name is required for the emoji'
/>
)
});
return;
} else if (/[^a-z0-9_-]/.test(emoji.name)) {
this.setState({
saving: false,
error: (
<FormattedMessage
id='add_emoji.nameInvalid'
defaultMessage="An emoji's name can only contain lowercase letters, numbers, and the symbols '-' and '_'."
/>
)
});
return;
} else if (EmojiStore.getSystemEmojis().has(emoji.name)) {
this.setState({
saving: false,
error: (
<FormattedMessage
id='add_emoji.nameTaken'
defaultMessage='This name is already in use by a system emoji. Please choose another name.'
/>
)
});
return;
}
if (!this.state.image) {
this.setState({
saving: false,
error: (
<FormattedMessage
id='add_emoji.imageRequired'
defaultMessage='An image is required for the emoji'
/>
)
});
return;
}
AsyncClient.addEmoji(
emoji,
this.state.image,
() => {
// for some reason, browserHistory.push doesn't trigger a state change even though the url changes
this.context.router.push('/' + this.props.team.name + '/emoji');
},
(err) => {
this.setState({
saving: false,
error: err.message
});
}
);
}
updateName(e) {
this.setState({
name: e.target.value