// Package imports. import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages } from 'react-intl'; 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'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; // 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'; import ComposerDirectWarning from './direct_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'; import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; const messages = defineMessages({ missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', defaultMessage: 'Send anyway' }, }); // State mapping. function mapStateToProps (state) { const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); const inReplyTo = state.getIn(['compose', 'in_reply_to']); const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null; const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']); const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null; let sideArmPrivacy = null; switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) { case 'copy': sideArmPrivacy = replyPrivacy; break; case 'restrict': sideArmPrivacy = sideArmRestrictedPrivacy; break; } sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy; 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']), caretPosition: state.getIn(['compose', 'caretPosition']), isSubmitting: state.getIn(['compose', 'is_submitting']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']), 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']), inReplyTo: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null, replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null, resetFileKey: state.getIn(['compose', 'resetFileKey']), sideArm: sideArmPrivacy, sensitive: state.getIn(['compose', 'sensitive']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), spoiler: spoilersAlwaysOn || 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, spoilersAlwaysOn: spoilersAlwaysOn, mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), }; }; // Dispatch mapping. const mapDispatchToProps = (dispatch, { intl }) => ({ onCancelReply() { dispatch(cancelReplyCompose()); }, onChangeAdvancedOption(option, value) { dispatch(changeComposeAdvancedOption(option, value)); }, onChangeDescription(id, description) { dispatch(changeUploadCompose(id, { description })); }, onChangeSensitivity() { dispatch(changeComposeSensitivity()); }, onChangeSpoilerText(text) { dispatch(changeComposeSpoilerText(text)); }, onChangeSpoilerness() { dispatch(changeComposeSpoilerness()); }, onChangeText(text) { dispatch(changeCompose(text)); }, onChangeVisibility(value) { dispatch(changeComposeVisibility(value)); }, onClearSuggestions() { dispatch(clearComposeSuggestions()); }, onCloseModal() { dispatch(closeModal()); }, onFetchSuggestions(token) { dispatch(fetchComposeSuggestions(token)); }, onInsertEmoji(position, emoji) { dispatch(insertEmojiCompose(position, emoji)); }, onMount() { dispatch(mountCompose()); }, onOpenActionsModal(props) { dispatch(openModal('ACTIONS', props)); }, onOpenDoodleModal() { dispatch(openModal('DOODLE', { noEsc: true })); }, onOpenFocalPointModal(id) { dispatch(openModal('FOCAL_POINT', { id })); }, onSelectSuggestion(position, token, suggestion) { dispatch(selectComposeSuggestion(position, token, suggestion)); }, onMediaDescriptionConfirm(routerHistory) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.missingDescriptionMessage), confirm: intl.formatMessage(messages.missingDescriptionConfirm), onConfirm: () => dispatch(submitCompose(routerHistory)), onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), })); }, onSubmit(routerHistory) { dispatch(submitCompose(routerHistory)); }, onUndoUpload(id) { dispatch(undoUploadCompose(id)); }, onUnmount() { dispatch(unmountCompose()); }, onUpload(files) { dispatch(uploadCompose(files)); }, }); // 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; 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; if (onSelectSuggestion) { onSelectSuggestion(tokenStart, token, value); } }, // Submits the status. handleSubmit () { const { textarea: { value }, uploadForm } = this; const { onChangeText, onSubmit, isSubmitting, isChangingUpload, isUploading, media, anyMedia, text, mediaDescriptionConfirmation, onMediaDescriptionConfirm, } = 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 || isChangingUpload || (!text.trim().length && !anyMedia)) { return; } // Submit unless there are media with missing descriptions if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { const firstWithoutDescription = media.findIndex(item => !item.get('description')); if (uploadForm) { const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input'); if (inputs.length == media.size && firstWithoutDescription !== -1) { inputs[firstWithoutDescription].focus(); } } onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null); } else if (onSubmit) { onSubmit(this.context.router ? this.context.router.history : null); } }, // Sets a reference to the upload form. handleRefUploadForm (uploadFormComponent) { this.uploadForm = uploadFormComponent; }, // Sets a reference to the textarea. handleRefTextarea (textareaComponent) { if (textareaComponent) { this.textarea = textareaComponent.textarea; } }, // Sets a reference to the CW field. handleRefSpoilerText (spoilerComponent) { if (spoilerComponent) { this.spoilerText = spoilerComponent.spoilerText; } } }; // The component. class Composer extends React.Component { // Constructor. constructor (props) { super(props); assignHandlers(this, handlers); // Instance variables. this.textarea = null; this.spoilerText = null; } // 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. componentDidUpdate (prevProps) { const { textarea, spoilerText, } = this; const { focusDate, caretPosition, isSubmitting, preselectDate, text, preselectOnReply, } = this.props; let selectionEnd, selectionStart; // Caret/selection handling. if (focusDate !== prevProps.focusDate) { switch (true) { case preselectDate !== prevProps.preselectDate && preselectOnReply: selectionStart = text.search(/\s/) + 1; selectionEnd = text.length; break; case !isNaN(caretPosition) && caretPosition !== null: selectionStart = selectionEnd = caretPosition; break; default: selectionStart = selectionEnd = text.length; } if (textarea) { textarea.setSelectionRange(selectionStart, selectionEnd); textarea.focus(); textarea.scrollIntoView(); } // Refocuses the textarea after submitting. } else if (textarea && prevProps.isSubmitting && !isSubmitting) { textarea.focus(); } else if (this.props.spoiler !== prevProps.spoiler) { if (this.props.spoiler) { if (spoilerText) { spoilerText.focus(); } } else { if (textarea) { textarea.focus(); } } } } render () { const { handleChangeSpoiler, handleEmoji, handleSecondarySubmit, handleSelect, handleSubmit, handleRefUploadForm, handleRefTextarea, handleRefSpoilerText, } = this.handlers; const { acceptContentTypes, advancedOptions, amUnlocked, anyMedia, intl, isSubmitting, isChangingUpload, isUploading, layout, media, onCancelReply, onChangeAdvancedOption, onChangeDescription, onChangeSensitivity, onChangeSpoilerness, onChangeText, onChangeVisibility, onClearSuggestions, onCloseModal, onFetchSuggestions, onOpenActionsModal, onOpenDoodleModal, onOpenFocalPointModal, onUndoUpload, onUpload, privacy, progress, inReplyTo, resetFileKey, sensitive, showSearch, sideArm, spoiler, spoilerText, suggestions, text, spoilersAlwaysOn, } = this.props; let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); return (