import React from 'react';
import ReactDOM from 'react-dom';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Flux from '../lib/flux';
import lodash from 'lodash';
import classNames from 'classnames';
import EmojiStore from '../stores/EmojiStore';
import Textarea from './common/TextareaAutosize';
import Spinner from './common/Spinner';
import PresenceStore from '../stores/PresenceStore';
import ChannelStore from '../stores/ChannelStore';
import GuildStore from '../stores/GuildStore';
import StreamerModeStore from '../stores/StreamerModeStore';
import UserStore from '../stores/UserStore';
import Avatar from './common/Avatar';
import FileInput from './common/FileInput';
import DiscordTag from './common/DiscordTag';
import PermissionMixin from '../mixins/PermissionMixin';
import IntegrationActionCreators from '../actions/IntegrationActionCreators';
import IntegrationQueryStore from '../stores/IntegrationQueryStore';
import UserSettingsModalActionCreators from '../actions/UserSettingsModalActionCreators';
import AutocompleteUtils from '../utils/AutocompleteUtils';
import i18n from '../i18n';
import InProgressTextCreators from '../actions/InProgressTextCreators';
import UserSettingsStore from '../stores/UserSettingsStore';
import RegexUtils from '../utils/RegexUtils';
import UploadMixin from '../mixins/web/UploadMixin';
import EmojiButton from './EmojiPicker';
import ComponentDispatchMixin from '../mixins/ComponentDispatchMixin';
import {
Permissions,
ChannelTypes,
TextareaTypes,
StatusTypes,
MAX_USER_AUTOCOMPLETE_RESULTS,
ComponentActions,
UserSettingsSections,
} from '../Constants';
import './ChannelTextarea.styl';
const RESULTS_REF = 'results';
const COMMAND_ENABLED = () => true;
const COMMANDS = [
{
command: 'gamerescape',
title: 'Gamer Escape',
description: i18n.Messages.COMMAND_GAMER_ESCAPE_DESCRIPTION,
integration: true,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'xivdb',
title: 'XIVDB',
description: i18n.Messages.COMMAND_XIVDB_DESCRIPTION,
integration: true,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'giphy',
title: 'Giphy',
description: i18n.Messages.COMMAND_GIPHY_DESCRIPTION,
integration: true,
enabled: COMMAND_ENABLED,
images: true,
},
{
command: 'tenor',
title: 'Tenor',
description: i18n.Messages.COMMAND_GIPHY_DESCRIPTION, // Just reuse Giphy description.
integration: true,
enabled: COMMAND_ENABLED,
images: true,
},
{
command: 'tts',
description: i18n.Messages.COMMAND_TTS_DESCRIPTION,
integration: false,
enabled: (can, context) => UserSettingsStore.enableTTSCommand && can(Permissions.SEND_TSS_MESSAGES, context),
images: false,
},
{
command: 'me',
description: i18n.Messages.COMMAND_ME_DESCRIPTION,
integration: false,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'tableflip',
description: i18n.Messages.COMMAND_TABLEFLIP_DESCRIPTION,
integration: false,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'unflip',
description: i18n.Messages.COMMAND_TABLEUNFLIP_DESCRIPTION,
integration: false,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'shrug',
description: i18n.Messages.COMMAND_SHRUG_DESCRIPTION,
integration: false,
enabled: COMMAND_ENABLED,
images: false,
},
{
command: 'nick',
description: i18n.Messages.COMMAND_NICK_DESCRIPTION,
integration: false,
enabled: (can, context) => can(Permissions.CHANGE_NICKNAME, context) || can(Permissions.MANAGE_NICKNAMES, context),
images: false,
},
];
const MENTION_EVERYONE = {
content: 'everyone',
description: i18n.Messages.MENTION_EVERYONE_AUTOCOMPLETE_DESCRIPTION,
};
const MENTION_HERE = {
content: 'here',
description: i18n.Messages.MENTION_HERE_AUTOCOMPLETE_DESCRIPTION,
};
const MENTION_SENTINEL = '@';
const CHANNEL_SENTINEL = '#';
const EMOJI_SENTINEL = ':';
const COMMAND_SENTINEL = '/';
const PREFIX_RE = new RegExp(`${MENTION_SENTINEL}|${CHANNEL_SENTINEL}|${EMOJI_SENTINEL}|^${COMMAND_SENTINEL}`);
const COMMAND_RE = new RegExp(`^/(${COMMANDS.filter(c => c.integration).map(c => c.command).join('|')})\\s(.+)`, 'i');
const WHITESPACE_RE = /(\t|\s)/;
const TYPE_NONE = 0;
const TYPE_MENTION = 1;
const TYPE_EMOJI = 2;
const TYPE_COMMAND = 3;
const TYPE_INTEGRATION = 4;
const TYPE_CHANNEL = 5;
const UploadButton = React.createClass({
mixins: [UploadMixin, ComponentDispatchMixin, PureRenderMixin],
getSubscriptions() {
return {
[ComponentActions.UPLOAD_FILE]: this.uploadFile,
};
},
uploadFile() {
if (this._input) {
this._input.activateUploadDialogue();
}
},
handleFileChange(e) {
e.stopPropagation();
e.preventDefault();
this.promptToUpload(e.target.files, this.props.channel.id);
},
setRef(ref) {
this._input = ref;
},
render() {
return (
// Can't wrap input in a button for firefox: http://bit.ly/1AOwTFd
);
},
});
const ResultsMixin = {
getInitialState() {
return {
selectedIndex: 0,
};
},
getDefaultProps() {
return {
prefix: '',
query: '',
};
},
handleKeyDown(e) {
const results = this.state.results || this.props.results;
if (results == null || results.length === 0 || this.state.loading) return;
let selectedIndex = this.state.selectedIndex;
switch (e.which) {
case 40: // DOWN
e.preventDefault();
if (++selectedIndex >= results.length) {
selectedIndex = 0;
}
this.setState({selectedIndex});
break;
case 38: // UP
e.preventDefault();
if (--selectedIndex < 0) {
selectedIndex = results.length - 1;
}
this.setState({selectedIndex});
break;
case 9: // TAB
case 13: // ENTER
e.preventDefault();
this.handleSelect();
break;
}
},
setSelectedIndex(selectedIndex) {
this.setState({selectedIndex});
},
handleSelect(e = null) {
const results = this.state.results || this.props.results;
if (results == null || results.length === 0 || this.state.loading) return;
let value = results[this.state.selectedIndex];
switch (this.props.type) {
case TYPE_MENTION:
if (value.content) {
value = MENTION_SENTINEL + value.content;
} else {
const user = value.user;
if (StreamerModeStore.hidePersonalInformation) {
value = MENTION_SENTINEL + user.username;
} else {
value = MENTION_SENTINEL + `${user.username}#${user.discriminator}`;
}
}
break;
case TYPE_EMOJI:
value = EMOJI_SENTINEL + value.name + EMOJI_SENTINEL;
break;
case TYPE_COMMAND:
value = COMMAND_SENTINEL + value.command;
break;
case TYPE_INTEGRATION:
value = value.url;
break;
case TYPE_CHANNEL:
value = CHANNEL_SENTINEL + value.name;
break;
}
e && e.preventDefault();
this.props.onSelect(value);
},
render() {
let results;
if (this.state.loading) {
results = ;
} else {
const renderRow = (result, i) => {
return this.renderRow(result, {
key: i,
className: classNames({active: this.state.selectedIndex === i}),
onMouseDown: this.handleSelect,
onMouseEnter: () => this.setSelectedIndex(i),
});
};
results = (
{(this.props.results || this.state.results).map(renderRow)}
);
}
return (
);
},
};
const IntegrationResults = React.createClass({
mixins: [ResultsMixin, Flux.StoreListenerMixin(IntegrationQueryStore)],
getStateFromStores() {
return this.getResults(this.props);
},
componentWillReceiveProps(nextProps) {
this.setState(this.getResults(nextProps));
},
getResults(props) {
let results = IntegrationQueryStore.getResults(props.integration, props.query);
if (this.props.command.images && this.isMounted()) {
let width = ReactDOM.findDOMNode(this).offsetWidth;
const newResults = results.results.filter(result => {
if (width < 0) {
return false;
} else {
width -= result.width + 15;
return true;
}
});
results = {...results, results: newResults};
}
return results;
},
renderHeader() {
const command = this.props.command.title;
const query = this.props.query;
return {i18n.Messages.CONTENT_MATCHING.format({command, query})}
;
},
renderRow(result, props) {
if (this.props.command.images) {
return (
);
} else {
let icon;
if (result['icon_url']) {
icon =
;
}
let type;
if (result['type']) {
type = {result['type']};
}
return (
{icon}
{result.title}
{type}
);
}
},
});
const EmojiResults = React.createClass({
mixins: [ResultsMixin],
renderHeader() {
const items = [];
if (this.props.prefix.length > 1) {
items.push(
{i18n.Messages.EMOJI_MATCHING.format({prefix: this.props.prefix})}
);
} else {
items.push({i18n.Messages.EMOJI}
);
}
const user = UserStore.getCurrentUser();
if (!user.premium) {
// preventDefault is required onMouseDown to stop blur from firing on
// ChannelTextArea and hiding the autocomplete and thus not allowing
// click to fire
items.push(
{i18n.Messages.PREMIUM_PROMO_AUTOCOMPLETE}
{' — '}
event.preventDefault()}
onClick={event => {
event.preventDefault();
UserSettingsModalActionCreators.open(UserSettingsSections.PREMIUM);
}}>
{i18n.Messages.PREMIUM_PROMO_AUTOCOMPLETE_CTA}
);
}
return items;
},
renderRow(emoji, props) {
const emojiPattern = EMOJI_SENTINEL + emoji.name + EMOJI_SENTINEL;
let emojiPreview;
if (emoji.url) {
emojiPreview =
;
} else {
emojiPreview = {emoji.surrogates};
}
return (
{emojiPreview}
{emojiPattern}
);
},
});
const MentionResults = React.createClass({
mixins: [ResultsMixin],
renderHeader() {
if (this.props.prefix.length > 1) {
return {i18n.Messages.MEMBERS_MATCHING.format({prefix: this.props.prefix})}
;
} else {
return i18n.Messages.MEMBERS;
}
},
renderRow(result, props) {
if (result.user != null) {
const user = result.user;
const status = PresenceStore.getStatus(user.id);
const avatar = ;
const tag = ;
if (result.nick != null) {
return (
{avatar}
{result.nick}
{tag}
);
} else {
return (
{avatar}
{tag}
);
}
}
return (
{MENTION_SENTINEL + result.content}
{result.description}
);
},
});
const CommandResults = React.createClass({
mixins: [ResultsMixin],
renderHeader() {
if (this.props.prefix.length > 1) {
return {i18n.Messages.COMMANDS_MATCHING.format({prefix: this.props.prefix})}
;
} else {
return i18n.Messages.COMMANDS;
}
},
renderRow({command, description}, props) {
return (
{COMMAND_SENTINEL + command}
{description}
);
},
});
const ChannelResults = React.createClass({
mixins: [ResultsMixin],
renderHeader() {
if (this.props.prefix.length > 1) {
return {i18n.Messages.TEXT_CHANNELS_MATCHING.format({prefix: this.props.prefix})}
;
} else {
return i18n.Messages.TEXT_CHANNELS;
}
},
renderRow(channel, props) {
return (
{channel.toString()}
);
},
});
const ChannelTextArea = React.createClass({
mixins: [PermissionMixin, UploadMixin, ComponentDispatchMixin],
propTypes: {
onSubmit: React.PropTypes.func.isRequired,
onFocus: React.PropTypes.func,
onBlur: React.PropTypes.func,
placeholder: React.PropTypes.string,
type: React.PropTypes.string,
defaultValue: React.PropTypes.string,
allowSlashCommands: React.PropTypes.bool,
inputType: React.PropTypes.string,
blurEvent: React.PropTypes.string,
},
getDefaultProps() {
return {
type: TextareaTypes.NORMAL,
defaultValue: '',
blurEvent: ComponentActions.TEXTAREA_BLUR,
};
},
getInitialState() {
return {
type: TYPE_NONE,
focused: false,
prefix: null,
};
},
getSubscriptions() {
const subscriptions = {
[ComponentActions.INSERT_TEXT]: this.handleInsertText,
};
if (this.props.blurEvent) {
subscriptions[this.props.blurEvent] = this.forceBlur;
}
return subscriptions;
},
forceBlur() {
if (this.refs.textarea) {
this.refs.textarea.blur();
}
},
handleInsertText({content}) {
this.insertText(content);
this.focus();
},
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.channel.id !== nextProps.channel.id ||
this.props.placeholder !== nextProps.placeholder ||
this.state.type !== nextState.type ||
this.state.focused !== nextState.focused ||
this.state.prefix !== nextState.prefix ||
this.state.integration !== nextState.integration ||
this.state.query !== nextState.query ||
this.didPermissionsUpdate(nextState, nextProps.channel) ||
this.props.type !== nextProps.type
);
},
componentWillUnmount() {
if (this.props.type === TextareaTypes.NORMAL) {
InProgressTextCreators.saveCurrentText(this.props.channel.id, this.getValue());
}
},
componentDidMount() {
// move cursor to the end of the text field we were editing, if there was any restored text
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
},
clearValue() {
InProgressTextCreators.saveCurrentText(this.props.channel.id, '');
this.refs.textarea.clear();
},
getValue() {
return ReactDOM.findDOMNode(this.refs.textarea).value.trim();
},
setValue(val) {
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
if (textarea) {
textarea.value = val;
this.refs.textarea.recalculateSize();
}
},
hasOpenCodeBlock() {
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
const match = textarea.value.slice(0, textarea.selectionStart).match(/```/g);
return match != null && match.length > 0 && match.length % 2 !== 0;
},
focus() {
ReactDOM.findDOMNode(this.refs.textarea).focus();
},
handleFocus(e) {
if (this.props.onFocus) {
this.props.onFocus(e);
}
this.setState({focused: true});
},
handleBlur(e) {
if (this.props.onBlur) {
this.props.onBlur(e);
}
this.setState({focused: false});
},
handleKeyPress(e) {
if (e.which === 13 /* RETURN */ && !(e.shiftKey || e.ctrlKey) && !this.hasOpenCodeBlock()) {
e.preventDefault();
const value = this.getValue();
if (this.props.onSubmit(value) && this.props.type === TextareaTypes.NORMAL) {
this.clearValue();
}
}
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
},
handleKeyDown(e) {
const results = this.refs[RESULTS_REF];
if (results != null) {
results.handleKeyDown(e);
}
if (e.which === 9 /* TAB */) {
e.preventDefault();
return;
}
const value = this.getValue();
this.props.onKeyDown && this.props.onKeyDown(e, value);
},
handleKeyUp() {
this.maybeShowAutocomplete();
},
handlePasta(e) {
if (
this.props.type !== TextareaTypes.NORMAL ||
!e.clipboardData ||
!e.clipboardData.items ||
(!this.props.channel.isPrivate() && !this.can(Permissions.ATTACH_FILES, this.props.channel))
) {
return;
}
for (let i = 0; i < e.clipboardData.items.length; i++) {
const clipboardItem = e.clipboardData.items[i];
switch (clipboardItem.type) {
case 'image/png':
e.preventDefault();
const fileBlob = clipboardItem.getAsFile();
if (fileBlob == null) {
break;
}
// In chrome, some files will have an additional
// html node that contains the filename.
if (e.clipboardData.items.length === 2) {
e.clipboardData.items[0].getAsString(s => {
fileBlob.overrideName = this.extractFileName(s);
this.promptToUpload([fileBlob], this.props.channel.id);
});
} else {
this.promptToUpload([fileBlob], this.props.channel.id);
}
break;
}
}
},
extractFileName(htmlString) {
const div = document.createElement('div');
div.innerHTML = htmlString;
const img = div.querySelector('img');
if (img) {
const a = document.createElement('a');
a.href = img.src;
let filename = a.pathname.split('/').pop();
if (filename) {
filename = filename.replace(/\..+$/, '');
if (filename) {
return filename + '.png';
}
}
}
return undefined;
},
maybeShowAutocomplete(callback) {
// click event on textbox passes event obj
if (typeof callback !== 'function') {
callback = null;
}
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
let start = textarea.selectionStart;
const end = textarea.selectionEnd;
let type = TYPE_NONE;
const value = textarea.value;
let prefix;
let results;
const match = value.match(COMMAND_RE);
if (match) {
const integration = match[1].toLowerCase();
const query = match[2];
if (this.state.integration === integration && this.state.query === query) {
if (callback) {
callback();
}
return;
}
IntegrationActionCreators.search(integration, query);
this.setState(
{
type: TYPE_INTEGRATION,
integration,
command: COMMANDS.filter(c => c.command === integration)[0],
query,
prefix: null,
results: null,
start: 0,
end: value.length,
},
callback
);
return;
}
const channel = ChannelStore.getChannel(this.props.channel.id);
const guildId = channel ? channel.getGuildId() : null;
const guild = guildId ? GuildStore.getGuild(guildId) : null;
do {
if (PREFIX_RE.test(value[start])) {
if (start === 0 || WHITESPACE_RE.test(value[start - 1])) {
prefix = value.slice(start, end);
if (this.state.prefix !== prefix) {
const regex = new RegExp(`^${RegexUtils.escape(prefix.slice(1))}`, 'i');
const test = v => regex.test(v);
switch (prefix[0]) {
case MENTION_SENTINEL:
type = TYPE_MENTION;
results = AutocompleteUtils.queryChannelUsers(this.props.channel.id, prefix.slice(1));
if (
results.length < MAX_USER_AUTOCOMPLETE_RESULTS &&
(this.can(Permissions.MENTION_EVERYONE, this.props.channel) ||
this.props.channel.type === ChannelTypes.GROUP_DM)
) {
if (test(MENTION_EVERYONE.content)) {
results.push(MENTION_EVERYONE);
}
if (results.length < MAX_USER_AUTOCOMPLETE_RESULTS && test(MENTION_HERE.content)) {
results.push(MENTION_HERE);
}
}
if (guild && results.length < MAX_USER_AUTOCOMPLETE_RESULTS) {
lodash(guild.roles)
.filter(({mentionable, name}) => mentionable && test(name))
.map(role => ({
content: role.name,
colorString: role.colorString,
description: i18n.Messages.MENTION_USERS_WITH_ROLE,
}))
.take(MAX_USER_AUTOCOMPLETE_RESULTS - results.length)
.forEach(item => results.push(item));
}
break;
case EMOJI_SENTINEL:
type = TYPE_EMOJI;
if (prefix.length > 2) {
results = EmojiStore.search(channel, prefix.slice(1), 10);
} else {
results = [];
}
break;
case COMMAND_SENTINEL:
if (start === 0 && this.props.type !== TextareaTypes.FORM) {
type = TYPE_COMMAND;
results = COMMANDS.filter(
({command, enabled}) => enabled(this.can, this.props.channel) && test(command)
).slice(0, 10);
}
break;
case CHANNEL_SENTINEL:
type = TYPE_CHANNEL;
const currentChannel = ChannelStore.getChannel(this.props.channel.id);
// TODO replace with AutocompleteUtils.queryTextChannels
results = lodash(ChannelStore.getChannels())
.filter(channel => channel['guild_id'] === currentChannel['guild_id'])
.filter(channel => channel.type === ChannelTypes.GUILD_TEXT)
.filter(channel => channel !== currentChannel && test(channel.name.toLowerCase()))
.filter(channel => this.can(Permissions.READ_MESSAGES, channel))
.sortBy(channel => channel.name)
.take(10)
.value();
break;
}
} else {
type = this.state.type;
results = this.state.results;
}
}
break;
} else if (WHITESPACE_RE.test(value[start - 1])) {
break;
}
} while (--start >= 0);
this.setState({type, prefix, results, start, end, command: null, integration: null}, callback);
},
insertText(value, atCarrot = true) {
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
const before = textarea.value.slice(0, atCarrot ? textarea.selectionStart : this.state.start);
const after = textarea.value.slice(atCarrot ? textarea.selectionEnd : this.state.end);
value += ' ';
textarea.value = before + value + after;
textarea.selectionStart = textarea.selectionEnd = before.length + value.length;
this.setState({start: textarea.selectionStart, end: textarea.selectionEnd});
if (this.props.type === TextareaTypes.NORMAL) {
InProgressTextCreators.saveCurrentText(this.props.channel.id, textarea.value);
}
return textarea;
},
performAutocomplete(value) {
// Race condition fix for state start/end (tab right after entering char)
// (setState does not appear to instantly update)
this.maybeShowAutocomplete(() => {
this.insertText(value, false);
// closes autocomplete dialogs after use
this.maybeShowAutocomplete();
});
},
insertEmoji(emojiObject) {
if (emojiObject !== null) {
this.insertText(`:${emojiObject.name}:`);
}
const textarea = ReactDOM.findDOMNode(this.refs.textarea);
textarea.focus();
},
render() {
const disabled = !this.props.channel.isPrivate() && !this.can(Permissions.SEND_MESSAGES, this.props.channel);
let placeholder = this.props.placeholder;
if (disabled) {
placeholder = i18n.Messages.NO_SEND_MESSAGES_PERMISSION_PLACEHOLDER;
}
let uploadButton;
if (
this.props.type === TextareaTypes.NORMAL &&
(this.props.channel.isPrivate() || (this.can(Permissions.ATTACH_FILES, this.props.channel) && !disabled))
) {
uploadButton = ;
}
const autocompleteComponent =
this.state.focused &&
{
[TYPE_MENTION]: MentionResults,
[TYPE_EMOJI]: EmojiResults,
[TYPE_COMMAND]: CommandResults,
[TYPE_INTEGRATION]: IntegrationResults,
[TYPE_CHANNEL]: ChannelResults,
}[this.state.type];
let autocomplete;
if (autocompleteComponent) {
autocomplete = React.createElement(autocompleteComponent, {
key: this.state.query || this.state.prefix,
ref: RESULTS_REF,
type: this.state.type,
integration: this.state.integration,
command: this.state.command,
query: this.state.query,
prefix: this.state.prefix,
results: this.state.results,
onSelect: this.performAutocomplete,
});
}
const classes = {
'channel-textarea': true,
'channel-textarea-disabled': disabled,
'has-results':
this.state.focused &&
(this.state.integration != null || (this.state.results != null && this.state.results.length > 0)),
};
const channel = ChannelStore.getChannel(this.props.channel.id);
let emojiButton;
if (!disabled) {
emojiButton = ;
}
return (
{uploadButton}
{emojiButton}
{autocomplete}
);
},
});
export default ChannelTextArea;
// WEBPACK FOOTER //
// ./discord_app/components/ChannelTextArea.js