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
Этот коммит содержится в:
коммит произвёл
Joram Wilander
родитель
a65f1fc266
Коммит
dc2f2a8001
307
webapp/components/emoji/components/add_emoji.jsx
Обычный файл
307
webapp/components/emoji/components/add_emoji.jsx
Обычный файл
@@ -0,0 +1,307 @@
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
updateImage(e) {
|
||||
if (e.target.files.length === 0) {
|
||||
this.setState({
|
||||
image: null,
|
||||
imageUrl: ''
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const image = e.target.files[0];
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.setState({
|
||||
image,
|
||||
imageUrl: reader.result
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(image);
|
||||
}
|
||||
|
||||
render() {
|
||||
let filename = null;
|
||||
if (this.state.image) {
|
||||
filename = (
|
||||
<span className='add-emoji__filename'>
|
||||
{this.state.image.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let preview = null;
|
||||
if (this.state.imageUrl) {
|
||||
preview = (
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
htmlFor='preview'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='add_emoji.preview'
|
||||
defaultMessage='Preview'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-md-5 col-sm-8 add-emoji__preview'>
|
||||
<FormattedMessage
|
||||
id='add_emoji.preview.sentence'
|
||||
defaultMessage='This is a sentence with {image} in it.'
|
||||
values={{
|
||||
image: (
|
||||
<img
|
||||
className='emoticon'
|
||||
src={this.state.imageUrl}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='backstage-content row'>
|
||||
<BackstageHeader>
|
||||
<Link to={'/' + this.props.team.name + '/emoji'}>
|
||||
<FormattedMessage
|
||||
id='emoji_list.header'
|
||||
defaultMessage='Custom Emoji'
|
||||
/>
|
||||
</Link>
|
||||
<FormattedMessage
|
||||
id='add_emoji.header'
|
||||
defaultMessage='Add'
|
||||
/>
|
||||
</BackstageHeader>
|
||||
<div className='backstage-form'>
|
||||
<form
|
||||
className='form-horizontal'
|
||||
onSubmit={this.handleSubmit}
|
||||
>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
htmlFor='name'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='add_emoji.name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-md-5 col-sm-8'>
|
||||
<input
|
||||
id='name'
|
||||
type='text'
|
||||
maxLength='64'
|
||||
className='form-control'
|
||||
value={this.state.name}
|
||||
onChange={this.updateName}
|
||||
/>
|
||||
<div className='form__help'>
|
||||
<FormattedMessage
|
||||
id='add_emoji.name.help'
|
||||
defaultMessage="Choose a name for your emoji made of up to 64 characters consisting of lowercase letters, numbers, and the symbols '-' and '_'."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
htmlFor='image'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='add_emoji.image'
|
||||
defaultMessage='Image'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-md-5 col-sm-8'>
|
||||
<div>
|
||||
<div className='add-emoji__upload'>
|
||||
<button className='btn btn-primary'>
|
||||
<FormattedMessage
|
||||
id='add_emoji.image.button'
|
||||
defaultMessage='Select'
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type='file'
|
||||
accept='.jpg,.png,.gif'
|
||||
multiple={false}
|
||||
onChange={this.updateImage}
|
||||
/>
|
||||
</div>
|
||||
{filename}
|
||||
<div className='form__help'>
|
||||
<FormattedMessage
|
||||
id='add_emoji.image.help'
|
||||
defaultMessage='Choose the image for your emoji. The image can be a gif, png, or jpeg file with a max size of 64 KB and dimensions up to 128 by 128 pixels.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{preview}
|
||||
<div className='backstage-form__footer'>
|
||||
<FormError error={this.state.error}/>
|
||||
<Link
|
||||
className='btn btn-sm'
|
||||
to={'/' + this.props.team.name + '/emoji'}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='add_emoji.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</Link>
|
||||
<SpinnerButton
|
||||
className='btn btn-primary'
|
||||
type='submit'
|
||||
spinning={this.state.saving}
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='add_emoji.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
218
webapp/components/emoji/components/emoji_list.jsx
Обычный файл
218
webapp/components/emoji/components/emoji_list.jsx
Обычный файл
@@ -0,0 +1,218 @@
|
||||
// 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 TeamStore from 'stores/team_store.jsx';
|
||||
import * as Utils from 'utils/utils.jsx';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import EmojiListItem from './emoji_list_item.jsx';
|
||||
import {Link} from 'react-router';
|
||||
import LoadingScreen from 'components/loading_screen.jsx';
|
||||
|
||||
export default class EmojiList extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
team: React.propTypes.object.isRequired,
|
||||
user: React.propTypes.object.isRequired
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.canCreateEmojis = this.canCreateEmojis.bind(this);
|
||||
|
||||
this.handleEmojiChange = this.handleEmojiChange.bind(this);
|
||||
|
||||
this.deleteEmoji = this.deleteEmoji.bind(this);
|
||||
|
||||
this.updateFilter = this.updateFilter.bind(this);
|
||||
|
||||
this.state = {
|
||||
emojis: EmojiStore.getCustomEmojiMap(),
|
||||
loading: !EmojiStore.hasReceivedCustomEmojis(),
|
||||
filter: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
EmojiStore.addChangeListener(this.handleEmojiChange);
|
||||
|
||||
if (window.mm_config.EnableCustomEmoji === 'true') {
|
||||
AsyncClient.listEmoji();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
EmojiStore.removeChangeListener(this.handleEmojiChange);
|
||||
}
|
||||
|
||||
handleEmojiChange() {
|
||||
this.setState({
|
||||
emojis: EmojiStore.getCustomEmojiMap(),
|
||||
loading: !EmojiStore.hasReceivedCustomEmojis()
|
||||
});
|
||||
}
|
||||
|
||||
updateFilter(e) {
|
||||
this.setState({
|
||||
filter: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
deleteEmoji(emoji) {
|
||||
AsyncClient.deleteEmoji(emoji.id);
|
||||
}
|
||||
|
||||
canCreateEmojis() {
|
||||
if (global.window.mm_license.IsLicensed !== 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Utils.isSystemAdmin(this.props.user.roles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (window.mm_config.RestrictCustomEmojiCreation === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (window.mm_config.RestrictCustomEmojiCreation === 'admin') {
|
||||
// check whether the user is an admin on any of their teams
|
||||
for (const member of TeamStore.getTeamMembers()) {
|
||||
if (Utils.isAdmin(member.roles)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const filter = this.state.filter.toLowerCase();
|
||||
const isSystemAdmin = Utils.isSystemAdmin(this.props.user.roles);
|
||||
|
||||
let emojis = [];
|
||||
if (this.state.loading) {
|
||||
emojis.push(
|
||||
<LoadingScreen key='loading'/>
|
||||
);
|
||||
} else if (this.state.emojis.length === 0) {
|
||||
emojis.push(
|
||||
<tr className='backstage-list__item backstage-list__empty'>
|
||||
<td colSpan='4'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.empty'
|
||||
defaultMessage='No custom emoji found'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else {
|
||||
for (const [, emoji] of this.state.emojis) {
|
||||
let onDelete = null;
|
||||
if (isSystemAdmin || this.props.user.id === emoji.creator_id) {
|
||||
onDelete = this.deleteEmoji;
|
||||
}
|
||||
|
||||
emojis.push(
|
||||
<EmojiListItem
|
||||
key={emoji.id}
|
||||
emoji={emoji}
|
||||
onDelete={onDelete}
|
||||
filter={filter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let addLink = null;
|
||||
if (this.canCreateEmojis()) {
|
||||
addLink = (
|
||||
<Link
|
||||
className='add-link'
|
||||
to={'/' + this.props.team.name + '/emoji/add'}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='emoji_list.add'
|
||||
defaultMessage='Add Custom Emoji'
|
||||
/>
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='backstage-content emoji-list'>
|
||||
<div className='backstage-header'>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='emoji_list.header'
|
||||
defaultMessage='Custom Emoji'
|
||||
/>
|
||||
</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={Utils.localizeMessage('emoji_list.search', 'Search Custom Emoji')}
|
||||
value={this.state.filter}
|
||||
onChange={this.updateFilter}
|
||||
style={{flexGrow: 0, flexShrink: 0}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className='emoji-list__help'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.help'
|
||||
defaultMessage='Custom emoji are available to everyone on your server and will show up in the emoji autocomplete menu.'
|
||||
/>
|
||||
</span>
|
||||
<div className='backstage-list'>
|
||||
<table className='emoji-list__table'>
|
||||
<tr className='backstage-list__item emoji-list__table-header'>
|
||||
<th className='emoji-list__name'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
</th>
|
||||
<th className='emoji-list__image'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.image'
|
||||
defaultMessage='Image'
|
||||
/>
|
||||
</th>
|
||||
<th className='emoji-list__creator'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.creator'
|
||||
defaultMessage='Creator'
|
||||
/>
|
||||
</th>
|
||||
<th className='emoji-list_actions'>
|
||||
<FormattedMessage
|
||||
id='emoji_list.actions'
|
||||
defaultMessage='Actions'
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
{emojis}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
118
webapp/components/emoji/components/emoji_list_item.jsx
Обычный файл
118
webapp/components/emoji/components/emoji_list_item.jsx
Обычный файл
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import EmojiStore from 'stores/emoji_store.jsx';
|
||||
import UserStore from 'stores/user_store.jsx';
|
||||
import * as Utils from 'utils/utils.jsx';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
export default class EmojiListItem extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
emoji: React.PropTypes.object.isRequired,
|
||||
onDelete: React.PropTypes.func.isRequired,
|
||||
filter: React.PropTypes.string
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
|
||||
this.state = {
|
||||
creator: UserStore.getProfile(this.props.emoji.creator_id)
|
||||
};
|
||||
}
|
||||
|
||||
handleDelete(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onDelete(this.props.emoji);
|
||||
}
|
||||
|
||||
matchesFilter(emoji, creator, filter) {
|
||||
if (!filter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (emoji.name.toLowerCase().indexOf(filter) !== -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (creator) {
|
||||
if (creator.username.toLowerCase().indexOf(filter) !== -1 ||
|
||||
(creator.first_name && creator.first_name.toLowerCase().indexOf(filter)) ||
|
||||
(creator.last_name && creator.last_name.toLowerCase().indexOf(filter)) ||
|
||||
(creator.nickname && creator.nickname.toLowerCase().indexOf(filter))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const emoji = this.props.emoji;
|
||||
const creator = this.state.creator;
|
||||
const filter = this.props.filter ? this.props.filter.toLowerCase() : '';
|
||||
|
||||
if (!this.matchesFilter(emoji, creator, filter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let creatorName;
|
||||
if (creator) {
|
||||
creatorName = Utils.displayUsernameForUser(creator);
|
||||
|
||||
if (creatorName !== creator.username) {
|
||||
creatorName += ' (@' + creator.username + ')';
|
||||
}
|
||||
} else {
|
||||
creatorName = (
|
||||
<FormattedMessage
|
||||
id='emoji_list.somebody'
|
||||
defaultMessage='Somebody on another team'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let deleteButton = null;
|
||||
if (this.props.onDelete) {
|
||||
deleteButton = (
|
||||
<a
|
||||
href='#'
|
||||
onClick={this.handleDelete}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='emoji_list.delete'
|
||||
defaultMessage='Delete'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className='backstage-list__item'>
|
||||
<td className='emoji-list__name'>
|
||||
{':' + emoji.name + ':'}
|
||||
</td>
|
||||
<td className='emoji-list__image'>
|
||||
<img
|
||||
className='emoticon'
|
||||
src={EmojiStore.getEmojiImageUrl(emoji)}
|
||||
/>
|
||||
</td>
|
||||
<td className='emoji-list__creator'>
|
||||
{creatorName}
|
||||
</td>
|
||||
<td className='emoji-list-item_actions'>
|
||||
{deleteButton}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user