summaryrefslogtreecommitdiffstats
path: root/webapp/components/emoji
diff options
context:
space:
mode:
authorHarrison Healey <harrisonmhealey@gmail.com>2016-07-05 11:58:18 -0400
committerJoram Wilander <jwawilander@gmail.com>2016-07-05 11:58:18 -0400
commitdc2f2a800105b77e665ec2a00c6290f35b1a2ba3 (patch)
tree82f23c2e72a7c785f55c2d6c1c35c10c16994918 /webapp/components/emoji
parenta65f1fc266f15eaa8f79541d4d11440c3d356bb6 (diff)
downloadchat-dc2f2a800105b77e665ec2a00c6290f35b1a2ba3.tar.gz
chat-dc2f2a800105b77e665ec2a00c6290f35b1a2ba3.tar.bz2
chat-dc2f2a800105b77e665ec2a00c6290f35b1a2ba3.zip
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
Diffstat (limited to 'webapp/components/emoji')
-rw-r--r--webapp/components/emoji/components/add_emoji.jsx307
-rw-r--r--webapp/components/emoji/components/emoji_list.jsx218
-rw-r--r--webapp/components/emoji/components/emoji_list_item.jsx118
3 files changed, 643 insertions, 0 deletions
diff --git a/webapp/components/emoji/components/add_emoji.jsx b/webapp/components/emoji/components/add_emoji.jsx
new file mode 100644
index 000000000..46f345476
--- /dev/null
+++ b/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>
+ );
+ }
+}
diff --git a/webapp/components/emoji/components/emoji_list.jsx b/webapp/components/emoji/components/emoji_list.jsx
new file mode 100644
index 000000000..5795a57b2
--- /dev/null
+++ b/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>
+ );
+ }
+}
diff --git a/webapp/components/emoji/components/emoji_list_item.jsx b/webapp/components/emoji/components/emoji_list_item.jsx
new file mode 100644
index 000000000..50a4bacb1
--- /dev/null
+++ b/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>
+ );
+ }
+}