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 = ( ); } return (
{this.renderHeader()}
{results}
); }, }; 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}