// 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 ( <div className='composer'> <ComposerSpoiler hidden={!spoiler} intl={intl} onChange={handleChangeSpoiler} onSubmit={handleSubmit} text={spoilerText} /> {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null} {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null} {replyContent ? ( <ComposerReply account={replyAccount} content={replyContent} intl={intl} onCancel={onCancelReply} /> ) : null} <ComposerTextarea advancedOptions={advancedOptions} autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} disabled={isSubmitting} intl={intl} onChange={onChangeText} onPaste={onUpload} onPickEmoji={handleEmoji} onSubmit={handleSubmit} onSuggestionsClearRequested={onClearSuggestions} onSuggestionsFetchRequested={onFetchSuggestions} onSuggestionSelected={handleSelect} ref={handleRefTextarea} suggestions={suggestions} value={text} /> {isUploading || media && media.size ? ( <ComposerUploadForm intl={intl} media={media} onChangeDescription={onChangeDescription} onRemove={onUndoUpload} progress={progress} uploading={isUploading} /> ) : null} <ComposerOptions acceptContentTypes={acceptContentTypes} advancedOptions={advancedOptions} disabled={isSubmitting} full={media ? media.size >= 4 || media.some( item => item.get('type') === 'video' ) : false} hasMedia={media && !!media.size} intl={intl} onChangeAdvancedOption={onChangeAdvancedOption} onChangeSensitivity={onChangeSensitivity} onChangeVisibility={onChangeVisibility} onDoodleOpen={onOpenDoodleModal} onModalClose={onCloseModal} onModalOpen={onOpenActionsModal} onToggleSpoiler={onChangeSpoilerness} onUpload={onUpload} privacy={privacy} resetFileKey={resetFileKey} sensitive={sensitive} spoiler={spoiler} /> <ComposerPublisher countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} disabled={disabledButton} intl={intl} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} privacy={privacy} sideArm={sideArm} /> </div> ); } } // Props. Composer.propTypes = { intl: PropTypes.object.isRequired, // State props. acceptContentTypes: PropTypes.string, advancedOptions: ImmutablePropTypes.map, amUnlocked: PropTypes.bool, focusDate: PropTypes.instanceOf(Date), isSubmitting: PropTypes.bool, isUploading: PropTypes.bool, layout: PropTypes.string, media: ImmutablePropTypes.list, preselectDate: PropTypes.instanceOf(Date), privacy: PropTypes.string, progress: PropTypes.number, replyAccount: PropTypes.string, replyContent: PropTypes.string, resetFileKey: PropTypes.number, sideArm: PropTypes.string, sensitive: PropTypes.bool, showSearch: PropTypes.bool, spoiler: PropTypes.bool, spoilerText: PropTypes.string, suggestionToken: PropTypes.string, suggestions: ImmutablePropTypes.list, text: PropTypes.string, // Dispatch props. onCancelReply: PropTypes.func, onChangeAdvancedOption: PropTypes.func, onChangeDescription: PropTypes.func, onChangeSensitivity: PropTypes.func, onChangeSpoilerText: PropTypes.func, onChangeSpoilerness: PropTypes.func, onChangeText: PropTypes.func, onChangeVisibility: PropTypes.func, onClearSuggestions: PropTypes.func, onCloseModal: PropTypes.func, onFetchSuggestions: PropTypes.func, onInsertEmoji: PropTypes.func, onMount: PropTypes.func, onOpenActionsModal: PropTypes.func, onOpenDoodleModal: PropTypes.func, onSelectSuggestion: PropTypes.func, onSubmit: PropTypes.func, onUndoUpload: PropTypes.func, onUnmount: PropTypes.func, onUpload: PropTypes.func, anyMedia: PropTypes.bool, }; // Connecting and export. export { Composer as WrappedComponent }; export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);