* 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
Этот коммит содержится в:
Harrison Healey
2016-07-05 11:58:18 -04:00
коммит произвёл Joram Wilander
родитель a65f1fc266
Коммит dc2f2a8001
56 изменённых файлов: 1697 добавлений и 8920 удалений

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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>
);
}
}