diff options
Diffstat (limited to 'app/javascript/mastodon/features/compose/components/compose_form.jsx')
-rw-r--r-- | app/javascript/mastodon/features/compose/components/compose_form.jsx | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx new file mode 100644 index 000000000..a40eb87e3 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -0,0 +1,303 @@ +import React from 'react'; +import CharacterCounter from './character_counter'; +import Button from '../../../components/button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import AutosuggestInput from '../../../components/autosuggest_input'; +import PollButtonContainer from '../containers/poll_button_container'; +import UploadButtonContainer from '../containers/upload_button_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import PollFormContainer from '../containers/poll_form_container'; +import UploadFormContainer from '../containers/upload_form_container'; +import WarningContainer from '../containers/warning_container'; +import LanguageDropdown from '../containers/language_dropdown_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { length } from 'stringz'; +import { countableText } from '../util/counter'; +import Icon from 'mastodon/components/icon'; +import { maxChars } from '../../../initial_state'; + +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, + }; + + 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({ }); + } + + 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(); + }).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 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> + + <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 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); |