// Package imports. import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i; // Actions. import { cancelReplyCompose, changeCompose, changeComposeAdvancedOption, changeComposeSensitivity, changeComposeSpoilerText, changeComposeSpoilerness, changeComposeVisibility, changeUploadCompose, clearComposeSuggestions, fetchComposeSuggestions, insertEmojiCompose, mountCompose, selectComposeSuggestion, submitCompose, undoUploadCompose, unmountCompose, uploadCompose, } from 'flavours/glitch/actions/compose'; import { closeModal, openModal, } from 'flavours/glitch/actions/modal'; // Components. import ComposerOptions from './options'; import ComposerPublisher from './publisher'; import ComposerReply from './reply'; import ComposerSpoiler from './spoiler'; import ComposerTextarea from './textarea'; import ComposerUploadForm from './upload_form'; import ComposerWarning from './warning'; import ComposerHashtagWarning from './hashtag_warning'; // Utils. import { countableText } from 'flavours/glitch/util/counter'; import { me } from 'flavours/glitch/util/initial_state'; import { isMobile } from 'flavours/glitch/util/is_mobile'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; import { wrap } from 'flavours/glitch/util/redux_helpers'; // State mapping. function mapStateToProps (state) { const inReplyTo = state.getIn(['compose', 'in_reply_to']); return { acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), advancedOptions: state.getIn(['compose', 'advanced_options']), amUnlocked: !state.getIn(['accounts', me, 'locked']), focusDate: state.getIn(['compose', 'focusDate']), isSubmitting: state.getIn(['compose', 'is_submitting']), isUploading: state.getIn(['compose', 'is_uploading']), layout: state.getIn(['local_settings', 'layout']), media: state.getIn(['compose', 'media_attachments']), preselectDate: state.getIn(['compose', 'preselectDate']), privacy: state.getIn(['compose', 'privacy']), progress: state.getIn(['compose', 'progress']), replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null, replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null, resetFileKey: state.getIn(['compose', 'resetFileKey']), sideArm: state.getIn(['local_settings', 'side_arm']), sensitive: state.getIn(['compose', 'sensitive']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), spoiler: state.getIn(['compose', 'spoiler']), spoilerText: state.getIn(['compose', 'spoiler_text']), suggestionToken: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']), text: state.getIn(['compose', 'text']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, }; }; // Dispatch mapping. const mapDispatchToProps = { onCancelReply: cancelReplyCompose, onChangeAdvancedOption: changeComposeAdvancedOption, onChangeDescription: changeUploadCompose, onChangeSensitivity: changeComposeSensitivity, onChangeSpoilerText: changeComposeSpoilerText, onChangeSpoilerness: changeComposeSpoilerness, onChangeText: changeCompose, onChangeVisibility: changeComposeVisibility, onClearSuggestions: clearComposeSuggestions, onCloseModal: closeModal, onFetchSuggestions: fetchComposeSuggestions, onInsertEmoji: insertEmojiCompose, onMount: mountCompose, onOpenActionsModal: openModal.bind(null, 'ACTIONS'), onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }), onSelectSuggestion: selectComposeSuggestion, onSubmit: submitCompose, onUndoUpload: undoUploadCompose, onUnmount: unmountCompose, onUpload: uploadCompose, }; // Handlers. const handlers = { // Changes the text value of the spoiler. handleChangeSpoiler ({ target: { value } }) { const { onChangeSpoilerText } = this.props; if (onChangeSpoilerText) { onChangeSpoilerText(value); } }, // Inserts an emoji at the caret. handleEmoji (data) { const { textarea: { selectionStart } } = this; const { onInsertEmoji } = this.props; this.caretPos = selectionStart + data.native.length + 1; if (onInsertEmoji) { onInsertEmoji(selectionStart, data); } }, // Handles the secondary submit button. handleSecondarySubmit () { const { handleSubmit } = this.handlers; const { onChangeVisibility, sideArm, } = this.props; if (sideArm !== 'none' && onChangeVisibility) { onChangeVisibility(sideArm); } handleSubmit(); }, // Selects a suggestion from the autofill. handleSelect (tokenStart, token, value) { const { onSelectSuggestion } = this.props; this.caretPos = null; if (onSelectSuggestion) { onSelectSuggestion(tokenStart, token, value); } }, // Submits the status. handleSubmit () { const { textarea: { value } } = this; const { onChangeText, onSubmit, isSubmitting, isUploading, anyMedia, text, } = this.props; // If something changes inside the textarea, then we update the // state before submitting. if (onChangeText && text !== value) { onChangeText(value); } // Submit disabled: if (isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia)) { return; } // Submits the status. if (onSubmit) { onSubmit(); } }, // Sets a reference to the textarea. handleRefTextarea (textareaComponent) { if (textareaComponent) { this.textarea = textareaComponent.textarea; } }, }; // The component. class Composer extends React.Component { // Constructor. constructor (props) { super(props); assignHandlers(this, handlers); // Instance variables. this.caretPos = null; this.textarea = null; } // If this is the update where we've finished uploading, // save the last caret position so we can restore it below! componentWillReceiveProps (nextProps) { const { textarea } = this; const { isUploading } = this.props; if (textarea && isUploading && !nextProps.isUploading) { this.caretPos = textarea.selectionStart; } } // Tells our state the composer has been mounted. componentDidMount () { const { onMount } = this.props; if (onMount) { onMount(); } } // Tells our state the composer has been unmounted. componentWillUnmount () { const { onUnmount } = this.props; if (onUnmount) { onUnmount(); } } // 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 we've just finished uploading an image, and have a saved // caret position, restores the cursor to that position after the // text changes. componentDidUpdate (prevProps) { const { caretPos, textarea, } = this; const { focusDate, isUploading, isSubmitting, preselectDate, text, } = this.props; let selectionEnd, selectionStart; // Caret/selection handling. if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) { switch (true) { case preselectDate !== prevProps.preselectDate: selectionStart = text.search(/\s/) + 1; selectionEnd = text.length; break; case !isNaN(caretPos) && caretPos !== null: selectionStart = selectionEnd = caretPos; break; default: selectionStart = selectionEnd = text.length; } if (textarea) { textarea.setSelectionRange(selectionStart, selectionEnd); textarea.focus(); } // Refocuses the textarea after submitting. } else if (textarea && prevProps.isSubmitting && !isSubmitting) { textarea.focus(); } } render () { const { handleChangeSpoiler, handleEmoji, handleSecondarySubmit, handleSelect, handleSubmit, handleRefTextarea, } = this.handlers; const { acceptContentTypes, advancedOptions, amUnlocked, anyMedia, intl, isSubmitting, isUploading, layout, media, onCancelReply, onChangeAdvancedOption, onChangeDescription, onChangeSensitivity, onChangeSpoilerness, onChangeText, onChangeVisibility, onClearSuggestions, onCloseModal, onFetchSuggestions, onOpenActionsModal, onOpenDoodleModal, onUndoUpload, onUpload, privacy, progress, replyAccount, replyContent, resetFileKey, sensitive, showSearch, sideArm, spoiler, spoilerText, suggestions, text, } = this.props; let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia); return (