456 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			456 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| //  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);
 |