959 lines
27 KiB
JavaScript
Executable file
959 lines
27 KiB
JavaScript
Executable file
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
|
|
<div className="channel-textarea-upload">
|
|
<FileInput ref={this.setRef} onChange={this.handleFileChange} multiple={true} />
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
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 = <Spinner />;
|
|
} 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 = (
|
|
<ul className={classNames({images: this.props.command && this.props.command.images})}>
|
|
{(this.props.results || this.state.results).map(renderRow)}
|
|
</ul>
|
|
);
|
|
}
|
|
return (
|
|
<div className="channel-textarea-autocomplete">
|
|
<div className="channel-textarea-autocomplete-inner">
|
|
<header>{this.renderHeader()}</header>
|
|
{results}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
};
|
|
|
|
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 <div>{i18n.Messages.CONTENT_MATCHING.format({command, query})}</div>;
|
|
},
|
|
|
|
renderRow(result, props) {
|
|
if (this.props.command.images) {
|
|
return (
|
|
<li {...props}>
|
|
<img width={result['width']} height={result['height']} src={result['src']} />
|
|
</li>
|
|
);
|
|
} else {
|
|
let icon;
|
|
if (result['icon_url']) {
|
|
icon = <img className="command-icon" src={result['icon_url']} />;
|
|
}
|
|
let type;
|
|
if (result['type']) {
|
|
type = <span className="command-description">{result['type']}</span>;
|
|
}
|
|
return (
|
|
<li {...props}>
|
|
{icon}
|
|
<span className="command">{result.title}</span>
|
|
{type}
|
|
</li>
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
const EmojiResults = React.createClass({
|
|
mixins: [ResultsMixin],
|
|
|
|
renderHeader() {
|
|
const items = [];
|
|
if (this.props.prefix.length > 1) {
|
|
items.push(
|
|
<div key="emoji-autocomplete-query">
|
|
{i18n.Messages.EMOJI_MATCHING.format({prefix: this.props.prefix})}
|
|
</div>
|
|
);
|
|
} else {
|
|
items.push(<div key="emoji-autocomplete-title">{i18n.Messages.EMOJI}</div>);
|
|
}
|
|
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(
|
|
<div key="emoji-autocomplete-promo" className="premium-promo-autocomplete">
|
|
{i18n.Messages.PREMIUM_PROMO_AUTOCOMPLETE}
|
|
{' — '}
|
|
<span
|
|
onMouseDown={event => event.preventDefault()}
|
|
onClick={event => {
|
|
event.preventDefault();
|
|
UserSettingsModalActionCreators.open(UserSettingsSections.PREMIUM);
|
|
}}>
|
|
{i18n.Messages.PREMIUM_PROMO_AUTOCOMPLETE_CTA}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
return items;
|
|
},
|
|
|
|
renderRow(emoji, props) {
|
|
const emojiPattern = EMOJI_SENTINEL + emoji.name + EMOJI_SENTINEL;
|
|
let emojiPreview;
|
|
|
|
if (emoji.url) {
|
|
emojiPreview = <img className="emoji" src={emoji.url} />;
|
|
} else {
|
|
emojiPreview = <span className="raw-emoji">{emoji.surrogates}</span>;
|
|
}
|
|
|
|
return (
|
|
<li {...props}>
|
|
{emojiPreview}
|
|
{emojiPattern}
|
|
</li>
|
|
);
|
|
},
|
|
});
|
|
|
|
const MentionResults = React.createClass({
|
|
mixins: [ResultsMixin],
|
|
|
|
renderHeader() {
|
|
if (this.props.prefix.length > 1) {
|
|
return <div>{i18n.Messages.MEMBERS_MATCHING.format({prefix: this.props.prefix})}</div>;
|
|
} else {
|
|
return i18n.Messages.MEMBERS;
|
|
}
|
|
},
|
|
|
|
renderRow(result, props) {
|
|
if (result.user != null) {
|
|
const user = result.user;
|
|
const status = PresenceStore.getStatus(user.id);
|
|
const avatar = <Avatar user={user} status={status === StatusTypes.OFFLINE ? null : status} />;
|
|
const tag = <DiscordTag user={user} />;
|
|
if (result.nick != null) {
|
|
return (
|
|
<li {...props}>
|
|
<span className="user">
|
|
{avatar}
|
|
<span className="username">{result.nick}</span>
|
|
</span>
|
|
<span className="user-description">
|
|
{tag}
|
|
</span>
|
|
</li>
|
|
);
|
|
} else {
|
|
return (
|
|
<li {...props}>
|
|
{avatar}
|
|
{tag}
|
|
</li>
|
|
);
|
|
}
|
|
}
|
|
return (
|
|
<li {...props} style={{color: result.colorString}}>
|
|
<span className="command">{MENTION_SENTINEL + result.content}</span>
|
|
<span className="command-description">{result.description}</span>
|
|
</li>
|
|
);
|
|
},
|
|
});
|
|
|
|
const CommandResults = React.createClass({
|
|
mixins: [ResultsMixin],
|
|
|
|
renderHeader() {
|
|
if (this.props.prefix.length > 1) {
|
|
return <div>{i18n.Messages.COMMANDS_MATCHING.format({prefix: this.props.prefix})}</div>;
|
|
} else {
|
|
return i18n.Messages.COMMANDS;
|
|
}
|
|
},
|
|
|
|
renderRow({command, description}, props) {
|
|
return (
|
|
<li {...props}>
|
|
<span className="command">{COMMAND_SENTINEL + command}</span>
|
|
<span className="command-description">{description}</span>
|
|
</li>
|
|
);
|
|
},
|
|
});
|
|
|
|
const ChannelResults = React.createClass({
|
|
mixins: [ResultsMixin],
|
|
|
|
renderHeader() {
|
|
if (this.props.prefix.length > 1) {
|
|
return <div>{i18n.Messages.TEXT_CHANNELS_MATCHING.format({prefix: this.props.prefix})}</div>;
|
|
} else {
|
|
return i18n.Messages.TEXT_CHANNELS;
|
|
}
|
|
},
|
|
|
|
renderRow(channel, props) {
|
|
return (
|
|
<li {...props}>
|
|
<span className="channel-name">{channel.toString()}</span>
|
|
</li>
|
|
);
|
|
},
|
|
});
|
|
|
|
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 = <UploadButton channel={this.props.channel} />;
|
|
}
|
|
|
|
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 = <EmojiButton onSelectEmoji={this.insertEmoji} name={this.props.type} channel={channel} />;
|
|
}
|
|
|
|
return (
|
|
<div className={classNames(classes)} onClick={this.focus}>
|
|
<div className="channel-textarea-inner">
|
|
{uploadButton}
|
|
<Textarea
|
|
ref="textarea"
|
|
rows={1}
|
|
fontWidthEstimate={6}
|
|
autoFocus={true}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
onChange={this.props.onChange}
|
|
onResize={this.props.onResize}
|
|
onKeyUp={this.handleKeyUp}
|
|
onKeyDown={this.handleKeyDown}
|
|
onKeyPress={this.handleKeyPress}
|
|
onFocus={this.handleFocus}
|
|
onBlur={this.handleBlur}
|
|
onPaste={this.handlePasta}
|
|
onClick={this.maybeShowAutocomplete}
|
|
defaultValue={this.props.defaultValue}
|
|
/>
|
|
{emojiButton}
|
|
</div>
|
|
{autocomplete}
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
export default ChannelTextArea;
|
|
|
|
|
|
|
|
// WEBPACK FOOTER //
|
|
// ./discord_app/components/ChannelTextArea.js
|