1cd7f5ff17
Conflicts: - `.eslintrc.js`: Upstream moved a configuration block in which we had added a glitch-only path. Moved the configuration block as upstream did. - other files: Upstream reordered imports, and those files had different ones. Kept our version and reordered imports using the same rules.
323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
import PropTypes from 'prop-types';
|
|
|
|
import { defineMessages, injectIntl } from 'react-intl';
|
|
|
|
import classNames from 'classnames';
|
|
|
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
|
|
import { length } from 'stringz';
|
|
|
|
import { Icon } from 'mastodon/components/icon';
|
|
|
|
import AutosuggestInput from '../../../components/autosuggest_input';
|
|
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
|
import Button from '../../../components/button';
|
|
import { maxChars } from '../../../initial_state';
|
|
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
|
import LanguageDropdown from '../containers/language_dropdown_container';
|
|
import PollButtonContainer from '../containers/poll_button_container';
|
|
import PollFormContainer from '../containers/poll_form_container';
|
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
|
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
|
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
|
import UploadButtonContainer from '../containers/upload_button_container';
|
|
import UploadFormContainer from '../containers/upload_form_container';
|
|
import WarningContainer from '../containers/warning_container';
|
|
import { countableText } from '../util/counter';
|
|
|
|
import CharacterCounter from './character_counter';
|
|
|
|
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
|
|
|
const messages = defineMessages({
|
|
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
|
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
|
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' },
|
|
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
|
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
|
});
|
|
|
|
class ComposeForm extends ImmutablePureComponent {
|
|
|
|
static contextTypes = {
|
|
router: PropTypes.object,
|
|
};
|
|
|
|
static propTypes = {
|
|
intl: PropTypes.object.isRequired,
|
|
text: PropTypes.string.isRequired,
|
|
suggestions: ImmutablePropTypes.list,
|
|
spoiler: PropTypes.bool,
|
|
privacy: PropTypes.string,
|
|
spoilerText: PropTypes.string,
|
|
focusDate: PropTypes.instanceOf(Date),
|
|
caretPosition: PropTypes.number,
|
|
preselectDate: PropTypes.instanceOf(Date),
|
|
isSubmitting: PropTypes.bool,
|
|
isChangingUpload: PropTypes.bool,
|
|
isEditing: PropTypes.bool,
|
|
isUploading: PropTypes.bool,
|
|
onChange: PropTypes.func.isRequired,
|
|
onSubmit: PropTypes.func.isRequired,
|
|
onClearSuggestions: PropTypes.func.isRequired,
|
|
onFetchSuggestions: PropTypes.func.isRequired,
|
|
onSuggestionSelected: PropTypes.func.isRequired,
|
|
onChangeSpoilerText: PropTypes.func.isRequired,
|
|
onPaste: PropTypes.func.isRequired,
|
|
onPickEmoji: PropTypes.func.isRequired,
|
|
autoFocus: PropTypes.bool,
|
|
anyMedia: PropTypes.bool,
|
|
isInReply: PropTypes.bool,
|
|
singleColumn: PropTypes.bool,
|
|
lang: PropTypes.string,
|
|
};
|
|
|
|
static defaultProps = {
|
|
autoFocus: false,
|
|
};
|
|
|
|
state = {
|
|
highlighted: false,
|
|
};
|
|
|
|
handleChange = (e) => {
|
|
this.props.onChange(e.target.value);
|
|
};
|
|
|
|
handleKeyDown = (e) => {
|
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
|
this.handleSubmit();
|
|
}
|
|
};
|
|
|
|
getFulltextForCharacterCounting = () => {
|
|
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
|
|
};
|
|
|
|
canSubmit = () => {
|
|
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
|
|
const fulltext = this.getFulltextForCharacterCounting();
|
|
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
|
|
|
|
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
|
|
};
|
|
|
|
handleSubmit = (e) => {
|
|
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
|
|
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
|
// Update the state to match the current text
|
|
this.props.onChange(this.autosuggestTextarea.textarea.value);
|
|
}
|
|
|
|
if (!this.canSubmit()) {
|
|
return;
|
|
}
|
|
|
|
this.props.onSubmit(this.context.router ? this.context.router.history : null);
|
|
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
onSuggestionsClearRequested = () => {
|
|
this.props.onClearSuggestions();
|
|
};
|
|
|
|
onSuggestionsFetchRequested = (token) => {
|
|
this.props.onFetchSuggestions(token);
|
|
};
|
|
|
|
onSuggestionSelected = (tokenStart, token, value) => {
|
|
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
|
|
};
|
|
|
|
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
|
|
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
|
|
};
|
|
|
|
handleChangeSpoilerText = (e) => {
|
|
this.props.onChangeSpoilerText(e.target.value);
|
|
};
|
|
|
|
handleFocus = () => {
|
|
if (this.composeForm && !this.props.singleColumn) {
|
|
const { left, right } = this.composeForm.getBoundingClientRect();
|
|
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
|
this.composeForm.scrollIntoView();
|
|
}
|
|
}
|
|
};
|
|
|
|
componentDidMount () {
|
|
this._updateFocusAndSelection({ });
|
|
}
|
|
|
|
componentWillUnmount () {
|
|
if (this.timeout) clearTimeout(this.timeout);
|
|
}
|
|
|
|
componentDidUpdate (prevProps) {
|
|
this._updateFocusAndSelection(prevProps);
|
|
}
|
|
|
|
_updateFocusAndSelection = (prevProps) => {
|
|
// This statement does several things:
|
|
// - If we're beginning a reply, and,
|
|
// - Replying to zero or one users, places the cursor at the end of the textbox.
|
|
// - Replying to more than one user, selects any usernames past the first;
|
|
// this provides a convenient shortcut to drop everyone else from the conversation.
|
|
if (this.props.focusDate && this.props.focusDate !== prevProps.focusDate) {
|
|
let selectionEnd, selectionStart;
|
|
|
|
if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
|
|
selectionEnd = this.props.text.length;
|
|
selectionStart = this.props.text.search(/\s/) + 1;
|
|
} else if (typeof this.props.caretPosition === 'number') {
|
|
selectionStart = this.props.caretPosition;
|
|
selectionEnd = this.props.caretPosition;
|
|
} else {
|
|
selectionEnd = this.props.text.length;
|
|
selectionStart = selectionEnd;
|
|
}
|
|
|
|
// Because of the wicg-inert polyfill, the activeElement may not be
|
|
// immediately selectable, we have to wait for observers to run, as
|
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
|
Promise.resolve().then(() => {
|
|
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
|
|
this.autosuggestTextarea.textarea.focus();
|
|
this.setState({ highlighted: true });
|
|
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
|
|
}).catch(console.error);
|
|
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
|
this.autosuggestTextarea.textarea.focus();
|
|
} else if (this.props.spoiler !== prevProps.spoiler) {
|
|
if (this.props.spoiler) {
|
|
this.spoilerText.input.focus();
|
|
} else if (prevProps.spoiler) {
|
|
this.autosuggestTextarea.textarea.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
setAutosuggestTextarea = (c) => {
|
|
this.autosuggestTextarea = c;
|
|
};
|
|
|
|
setSpoilerText = (c) => {
|
|
this.spoilerText = c;
|
|
};
|
|
|
|
setRef = c => {
|
|
this.composeForm = c;
|
|
};
|
|
|
|
handleEmojiPick = (data) => {
|
|
const { text } = this.props;
|
|
const position = this.autosuggestTextarea.textarea.selectionStart;
|
|
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
|
|
|
this.props.onPickEmoji(position, data, needsSpace);
|
|
};
|
|
|
|
render () {
|
|
const { intl, onPaste, autoFocus } = this.props;
|
|
const { highlighted } = this.state;
|
|
const disabled = this.props.isSubmitting;
|
|
|
|
let publishText = '';
|
|
|
|
if (this.props.isEditing) {
|
|
publishText = intl.formatMessage(messages.saveChanges);
|
|
} else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
|
|
publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
|
|
} else {
|
|
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
|
}
|
|
|
|
return (
|
|
<form className='compose-form' onSubmit={this.handleSubmit}>
|
|
<WarningContainer />
|
|
|
|
<ReplyIndicatorContainer />
|
|
|
|
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
|
|
<AutosuggestInput
|
|
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
|
value={this.props.spoilerText}
|
|
onChange={this.handleChangeSpoilerText}
|
|
onKeyDown={this.handleKeyDown}
|
|
disabled={!this.props.spoiler}
|
|
ref={this.setSpoilerText}
|
|
suggestions={this.props.suggestions}
|
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
|
searchTokens={[':']}
|
|
id='cw-spoiler-input'
|
|
className='spoiler-input__input'
|
|
lang={this.props.lang}
|
|
spellCheck
|
|
/>
|
|
</div>
|
|
|
|
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
|
|
<AutosuggestTextarea
|
|
ref={this.setAutosuggestTextarea}
|
|
placeholder={intl.formatMessage(messages.placeholder)}
|
|
disabled={disabled}
|
|
value={this.props.text}
|
|
onChange={this.handleChange}
|
|
suggestions={this.props.suggestions}
|
|
onFocus={this.handleFocus}
|
|
onKeyDown={this.handleKeyDown}
|
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
onSuggestionSelected={this.onSuggestionSelected}
|
|
onPaste={onPaste}
|
|
autoFocus={autoFocus}
|
|
lang={this.props.lang}
|
|
>
|
|
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
|
|
|
<div className='compose-form__modifiers'>
|
|
<UploadFormContainer />
|
|
<PollFormContainer />
|
|
</div>
|
|
</AutosuggestTextarea>
|
|
|
|
<div className='compose-form__buttons-wrapper'>
|
|
<div className='compose-form__buttons'>
|
|
<UploadButtonContainer />
|
|
<PollButtonContainer />
|
|
<PrivacyDropdownContainer disabled={this.props.isEditing} />
|
|
<SpoilerButtonContainer />
|
|
<LanguageDropdown />
|
|
</div>
|
|
|
|
<div className='character-counter__wrapper'>
|
|
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='compose-form__publish'>
|
|
<div className='compose-form__publish-button-wrapper'>
|
|
<Button
|
|
type='submit'
|
|
text={publishText}
|
|
disabled={!this.canSubmit()}
|
|
block
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
export default injectIntl(ComposeForm);
|