diff options
author | Harrison Healey <harrisonmhealey@gmail.com> | 2016-07-05 11:58:18 -0400 |
---|---|---|
committer | Joram Wilander <jwawilander@gmail.com> | 2016-07-05 11:58:18 -0400 |
commit | dc2f2a800105b77e665ec2a00c6290f35b1a2ba3 (patch) | |
tree | 82f23c2e72a7c785f55c2d6c1c35c10c16994918 /webapp/components/emoji | |
parent | a65f1fc266f15eaa8f79541d4d11440c3d356bb6 (diff) | |
download | chat-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.jsx | 307 | ||||
-rw-r--r-- | webapp/components/emoji/components/emoji_list.jsx | 218 | ||||
-rw-r--r-- | webapp/components/emoji/components/emoji_list_item.jsx | 118 |
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> + ); + } +} |