You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

456 lines
14 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. // Package imports.
  2. import PropTypes from 'prop-types';
  3. import React from 'react';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i;
  6. // Actions.
  7. import {
  8. cancelReplyCompose,
  9. changeCompose,
  10. changeComposeAdvancedOption,
  11. changeComposeSensitivity,
  12. changeComposeSpoilerText,
  13. changeComposeSpoilerness,
  14. changeComposeVisibility,
  15. changeUploadCompose,
  16. clearComposeSuggestions,
  17. fetchComposeSuggestions,
  18. insertEmojiCompose,
  19. mountCompose,
  20. selectComposeSuggestion,
  21. submitCompose,
  22. undoUploadCompose,
  23. unmountCompose,
  24. uploadCompose,
  25. } from 'flavours/glitch/actions/compose';
  26. import {
  27. closeModal,
  28. openModal,
  29. } from 'flavours/glitch/actions/modal';
  30. // Components.
  31. import ComposerOptions from './options';
  32. import ComposerPublisher from './publisher';
  33. import ComposerReply from './reply';
  34. import ComposerSpoiler from './spoiler';
  35. import ComposerTextarea from './textarea';
  36. import ComposerUploadForm from './upload_form';
  37. import ComposerWarning from './warning';
  38. import ComposerHashtagWarning from './hashtag_warning';
  39. // Utils.
  40. import { countableText } from 'flavours/glitch/util/counter';
  41. import { me } from 'flavours/glitch/util/initial_state';
  42. import { isMobile } from 'flavours/glitch/util/is_mobile';
  43. import { assignHandlers } from 'flavours/glitch/util/react_helpers';
  44. import { wrap } from 'flavours/glitch/util/redux_helpers';
  45. // State mapping.
  46. function mapStateToProps (state) {
  47. const inReplyTo = state.getIn(['compose', 'in_reply_to']);
  48. return {
  49. acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
  50. advancedOptions: state.getIn(['compose', 'advanced_options']),
  51. amUnlocked: !state.getIn(['accounts', me, 'locked']),
  52. focusDate: state.getIn(['compose', 'focusDate']),
  53. isSubmitting: state.getIn(['compose', 'is_submitting']),
  54. isUploading: state.getIn(['compose', 'is_uploading']),
  55. layout: state.getIn(['local_settings', 'layout']),
  56. media: state.getIn(['compose', 'media_attachments']),
  57. preselectDate: state.getIn(['compose', 'preselectDate']),
  58. privacy: state.getIn(['compose', 'privacy']),
  59. progress: state.getIn(['compose', 'progress']),
  60. replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
  61. replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
  62. resetFileKey: state.getIn(['compose', 'resetFileKey']),
  63. sideArm: state.getIn(['local_settings', 'side_arm']),
  64. sensitive: state.getIn(['compose', 'sensitive']),
  65. showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
  66. spoiler: state.getIn(['compose', 'spoiler']),
  67. spoilerText: state.getIn(['compose', 'spoiler_text']),
  68. suggestionToken: state.getIn(['compose', 'suggestion_token']),
  69. suggestions: state.getIn(['compose', 'suggestions']),
  70. text: state.getIn(['compose', 'text']),
  71. anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
  72. };
  73. };
  74. // Dispatch mapping.
  75. const mapDispatchToProps = {
  76. onCancelReply: cancelReplyCompose,
  77. onChangeAdvancedOption: changeComposeAdvancedOption,
  78. onChangeDescription: changeUploadCompose,
  79. onChangeSensitivity: changeComposeSensitivity,
  80. onChangeSpoilerText: changeComposeSpoilerText,
  81. onChangeSpoilerness: changeComposeSpoilerness,
  82. onChangeText: changeCompose,
  83. onChangeVisibility: changeComposeVisibility,
  84. onClearSuggestions: clearComposeSuggestions,
  85. onCloseModal: closeModal,
  86. onFetchSuggestions: fetchComposeSuggestions,
  87. onInsertEmoji: insertEmojiCompose,
  88. onMount: mountCompose,
  89. onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
  90. onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
  91. onSelectSuggestion: selectComposeSuggestion,
  92. onSubmit: submitCompose,
  93. onUndoUpload: undoUploadCompose,
  94. onUnmount: unmountCompose,
  95. onUpload: uploadCompose,
  96. };
  97. // Handlers.
  98. const handlers = {
  99. // Changes the text value of the spoiler.
  100. handleChangeSpoiler ({ target: { value } }) {
  101. const { onChangeSpoilerText } = this.props;
  102. if (onChangeSpoilerText) {
  103. onChangeSpoilerText(value);
  104. }
  105. },
  106. // Inserts an emoji at the caret.
  107. handleEmoji (data) {
  108. const { textarea: { selectionStart } } = this;
  109. const { onInsertEmoji } = this.props;
  110. this.caretPos = selectionStart + data.native.length + 1;
  111. if (onInsertEmoji) {
  112. onInsertEmoji(selectionStart, data);
  113. }
  114. },
  115. // Handles the secondary submit button.
  116. handleSecondarySubmit () {
  117. const { handleSubmit } = this.handlers;
  118. const {
  119. onChangeVisibility,
  120. sideArm,
  121. } = this.props;
  122. if (sideArm !== 'none' && onChangeVisibility) {
  123. onChangeVisibility(sideArm);
  124. }
  125. handleSubmit();
  126. },
  127. // Selects a suggestion from the autofill.
  128. handleSelect (tokenStart, token, value) {
  129. const { onSelectSuggestion } = this.props;
  130. this.caretPos = null;
  131. if (onSelectSuggestion) {
  132. onSelectSuggestion(tokenStart, token, value);
  133. }
  134. },
  135. // Submits the status.
  136. handleSubmit () {
  137. const { textarea: { value } } = this;
  138. const {
  139. onChangeText,
  140. onSubmit,
  141. isSubmitting,
  142. isUploading,
  143. anyMedia,
  144. text,
  145. } = this.props;
  146. // If something changes inside the textarea, then we update the
  147. // state before submitting.
  148. if (onChangeText && text !== value) {
  149. onChangeText(value);
  150. }
  151. // Submit disabled:
  152. if (isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia)) {
  153. return;
  154. }
  155. // Submits the status.
  156. if (onSubmit) {
  157. onSubmit();
  158. }
  159. },
  160. // Sets a reference to the textarea.
  161. handleRefTextarea (textareaComponent) {
  162. if (textareaComponent) {
  163. this.textarea = textareaComponent.textarea;
  164. }
  165. },
  166. };
  167. // The component.
  168. class Composer extends React.Component {
  169. // Constructor.
  170. constructor (props) {
  171. super(props);
  172. assignHandlers(this, handlers);
  173. // Instance variables.
  174. this.caretPos = null;
  175. this.textarea = null;
  176. }
  177. // If this is the update where we've finished uploading,
  178. // save the last caret position so we can restore it below!
  179. componentWillReceiveProps (nextProps) {
  180. const { textarea } = this;
  181. const { isUploading } = this.props;
  182. if (textarea && isUploading && !nextProps.isUploading) {
  183. this.caretPos = textarea.selectionStart;
  184. }
  185. }
  186. // Tells our state the composer has been mounted.
  187. componentDidMount () {
  188. const { onMount } = this.props;
  189. if (onMount) {
  190. onMount();
  191. }
  192. }
  193. // Tells our state the composer has been unmounted.
  194. componentWillUnmount () {
  195. const { onUnmount } = this.props;
  196. if (onUnmount) {
  197. onUnmount();
  198. }
  199. }
  200. // This statement does several things:
  201. // - If we're beginning a reply, and,
  202. // - Replying to zero or one users, places the cursor at the end
  203. // of the textbox.
  204. // - Replying to more than one user, selects any usernames past
  205. // the first; this provides a convenient shortcut to drop
  206. // everyone else from the conversation.
  207. // - If we've just finished uploading an image, and have a saved
  208. // caret position, restores the cursor to that position after the
  209. // text changes.
  210. componentDidUpdate (prevProps) {
  211. const {
  212. caretPos,
  213. textarea,
  214. } = this;
  215. const {
  216. focusDate,
  217. isUploading,
  218. isSubmitting,
  219. preselectDate,
  220. text,
  221. } = this.props;
  222. let selectionEnd, selectionStart;
  223. // Caret/selection handling.
  224. if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
  225. switch (true) {
  226. case preselectDate !== prevProps.preselectDate:
  227. selectionStart = text.search(/\s/) + 1;
  228. selectionEnd = text.length;
  229. break;
  230. case !isNaN(caretPos) && caretPos !== null:
  231. selectionStart = selectionEnd = caretPos;
  232. break;
  233. default:
  234. selectionStart = selectionEnd = text.length;
  235. }
  236. if (textarea) {
  237. textarea.setSelectionRange(selectionStart, selectionEnd);
  238. textarea.focus();
  239. }
  240. // Refocuses the textarea after submitting.
  241. } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
  242. textarea.focus();
  243. }
  244. }
  245. render () {
  246. const {
  247. handleChangeSpoiler,
  248. handleEmoji,
  249. handleSecondarySubmit,
  250. handleSelect,
  251. handleSubmit,
  252. handleRefTextarea,
  253. } = this.handlers;
  254. const {
  255. acceptContentTypes,
  256. advancedOptions,
  257. amUnlocked,
  258. anyMedia,
  259. intl,
  260. isSubmitting,
  261. isUploading,
  262. layout,
  263. media,
  264. onCancelReply,
  265. onChangeAdvancedOption,
  266. onChangeDescription,
  267. onChangeSensitivity,
  268. onChangeSpoilerness,
  269. onChangeText,
  270. onChangeVisibility,
  271. onClearSuggestions,
  272. onCloseModal,
  273. onFetchSuggestions,
  274. onOpenActionsModal,
  275. onOpenDoodleModal,
  276. onUndoUpload,
  277. onUpload,
  278. privacy,
  279. progress,
  280. replyAccount,
  281. replyContent,
  282. resetFileKey,
  283. sensitive,
  284. showSearch,
  285. sideArm,
  286. spoiler,
  287. spoilerText,
  288. suggestions,
  289. text,
  290. } = this.props;
  291. let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia);
  292. return (
  293. <div className='composer'>
  294. <ComposerSpoiler
  295. hidden={!spoiler}
  296. intl={intl}
  297. onChange={handleChangeSpoiler}
  298. onSubmit={handleSubmit}
  299. text={spoilerText}
  300. />
  301. {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
  302. {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
  303. {replyContent ? (
  304. <ComposerReply
  305. account={replyAccount}
  306. content={replyContent}
  307. intl={intl}
  308. onCancel={onCancelReply}
  309. />
  310. ) : null}
  311. <ComposerTextarea
  312. advancedOptions={advancedOptions}
  313. autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
  314. disabled={isSubmitting}
  315. intl={intl}
  316. onChange={onChangeText}
  317. onPaste={onUpload}
  318. onPickEmoji={handleEmoji}
  319. onSubmit={handleSubmit}
  320. onSuggestionsClearRequested={onClearSuggestions}
  321. onSuggestionsFetchRequested={onFetchSuggestions}
  322. onSuggestionSelected={handleSelect}
  323. ref={handleRefTextarea}
  324. suggestions={suggestions}
  325. value={text}
  326. />
  327. {isUploading || media && media.size ? (
  328. <ComposerUploadForm
  329. intl={intl}
  330. media={media}
  331. onChangeDescription={onChangeDescription}
  332. onRemove={onUndoUpload}
  333. progress={progress}
  334. uploading={isUploading}
  335. />
  336. ) : null}
  337. <ComposerOptions
  338. acceptContentTypes={acceptContentTypes}
  339. advancedOptions={advancedOptions}
  340. disabled={isSubmitting}
  341. full={media ? media.size >= 4 || media.some(
  342. item => item.get('type') === 'video'
  343. ) : false}
  344. hasMedia={media && !!media.size}
  345. intl={intl}
  346. onChangeAdvancedOption={onChangeAdvancedOption}
  347. onChangeSensitivity={onChangeSensitivity}
  348. onChangeVisibility={onChangeVisibility}
  349. onDoodleOpen={onOpenDoodleModal}
  350. onModalClose={onCloseModal}
  351. onModalOpen={onOpenActionsModal}
  352. onToggleSpoiler={onChangeSpoilerness}
  353. onUpload={onUpload}
  354. privacy={privacy}
  355. resetFileKey={resetFileKey}
  356. sensitive={sensitive}
  357. spoiler={spoiler}
  358. />
  359. <ComposerPublisher
  360. countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
  361. disabled={disabledButton}
  362. intl={intl}
  363. onSecondarySubmit={handleSecondarySubmit}
  364. onSubmit={handleSubmit}
  365. privacy={privacy}
  366. sideArm={sideArm}
  367. />
  368. </div>
  369. );
  370. }
  371. }
  372. // Props.
  373. Composer.propTypes = {
  374. intl: PropTypes.object.isRequired,
  375. // State props.
  376. acceptContentTypes: PropTypes.string,
  377. advancedOptions: ImmutablePropTypes.map,
  378. amUnlocked: PropTypes.bool,
  379. focusDate: PropTypes.instanceOf(Date),
  380. isSubmitting: PropTypes.bool,
  381. isUploading: PropTypes.bool,
  382. layout: PropTypes.string,
  383. media: ImmutablePropTypes.list,
  384. preselectDate: PropTypes.instanceOf(Date),
  385. privacy: PropTypes.string,
  386. progress: PropTypes.number,
  387. replyAccount: PropTypes.string,
  388. replyContent: PropTypes.string,
  389. resetFileKey: PropTypes.number,
  390. sideArm: PropTypes.string,
  391. sensitive: PropTypes.bool,
  392. showSearch: PropTypes.bool,
  393. spoiler: PropTypes.bool,
  394. spoilerText: PropTypes.string,
  395. suggestionToken: PropTypes.string,
  396. suggestions: ImmutablePropTypes.list,
  397. text: PropTypes.string,
  398. // Dispatch props.
  399. onCancelReply: PropTypes.func,
  400. onChangeAdvancedOption: PropTypes.func,
  401. onChangeDescription: PropTypes.func,
  402. onChangeSensitivity: PropTypes.func,
  403. onChangeSpoilerText: PropTypes.func,
  404. onChangeSpoilerness: PropTypes.func,
  405. onChangeText: PropTypes.func,
  406. onChangeVisibility: PropTypes.func,
  407. onClearSuggestions: PropTypes.func,
  408. onCloseModal: PropTypes.func,
  409. onFetchSuggestions: PropTypes.func,
  410. onInsertEmoji: PropTypes.func,
  411. onMount: PropTypes.func,
  412. onOpenActionsModal: PropTypes.func,
  413. onOpenDoodleModal: PropTypes.func,
  414. onSelectSuggestion: PropTypes.func,
  415. onSubmit: PropTypes.func,
  416. onUndoUpload: PropTypes.func,
  417. onUnmount: PropTypes.func,
  418. onUpload: PropTypes.func,
  419. anyMedia: PropTypes.bool,
  420. };
  421. // Connecting and export.
  422. export { Composer as WrappedComponent };
  423. export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);