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.

448 lines
13 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. text,
  142. } = this.props;
  143. // If something changes inside the textarea, then we update the
  144. // state before submitting.
  145. if (onChangeText && text !== value) {
  146. onChangeText(value);
  147. }
  148. // Submits the status.
  149. if (onSubmit) {
  150. onSubmit();
  151. }
  152. },
  153. // Sets a reference to the textarea.
  154. handleRefTextarea (textareaComponent) {
  155. if (textareaComponent) {
  156. this.textarea = textareaComponent.textarea;
  157. }
  158. },
  159. };
  160. // The component.
  161. class Composer extends React.Component {
  162. // Constructor.
  163. constructor (props) {
  164. super(props);
  165. assignHandlers(this, handlers);
  166. // Instance variables.
  167. this.caretPos = null;
  168. this.textarea = null;
  169. }
  170. // If this is the update where we've finished uploading,
  171. // save the last caret position so we can restore it below!
  172. componentWillReceiveProps (nextProps) {
  173. const { textarea } = this;
  174. const { isUploading } = this.props;
  175. if (textarea && isUploading && !nextProps.isUploading) {
  176. this.caretPos = textarea.selectionStart;
  177. }
  178. }
  179. // Tells our state the composer has been mounted.
  180. componentDidMount () {
  181. const { onMount } = this.props;
  182. if (onMount) {
  183. onMount();
  184. }
  185. }
  186. // Tells our state the composer has been unmounted.
  187. componentWillUnmount () {
  188. const { onUnmount } = this.props;
  189. if (onUnmount) {
  190. onUnmount();
  191. }
  192. }
  193. // This statement does several things:
  194. // - If we're beginning a reply, and,
  195. // - Replying to zero or one users, places the cursor at the end
  196. // of the textbox.
  197. // - Replying to more than one user, selects any usernames past
  198. // the first; this provides a convenient shortcut to drop
  199. // everyone else from the conversation.
  200. // - If we've just finished uploading an image, and have a saved
  201. // caret position, restores the cursor to that position after the
  202. // text changes.
  203. componentDidUpdate (prevProps) {
  204. const {
  205. caretPos,
  206. textarea,
  207. } = this;
  208. const {
  209. focusDate,
  210. isUploading,
  211. isSubmitting,
  212. preselectDate,
  213. text,
  214. } = this.props;
  215. let selectionEnd, selectionStart;
  216. // Caret/selection handling.
  217. if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
  218. switch (true) {
  219. case preselectDate !== prevProps.preselectDate:
  220. selectionStart = text.search(/\s/) + 1;
  221. selectionEnd = text.length;
  222. break;
  223. case !isNaN(caretPos) && caretPos !== null:
  224. selectionStart = selectionEnd = caretPos;
  225. break;
  226. default:
  227. selectionStart = selectionEnd = text.length;
  228. }
  229. if (textarea) {
  230. textarea.setSelectionRange(selectionStart, selectionEnd);
  231. textarea.focus();
  232. }
  233. // Refocuses the textarea after submitting.
  234. } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
  235. textarea.focus();
  236. }
  237. }
  238. render () {
  239. const {
  240. handleChangeSpoiler,
  241. handleEmoji,
  242. handleSecondarySubmit,
  243. handleSelect,
  244. handleSubmit,
  245. handleRefTextarea,
  246. } = this.handlers;
  247. const {
  248. acceptContentTypes,
  249. advancedOptions,
  250. amUnlocked,
  251. anyMedia,
  252. intl,
  253. isSubmitting,
  254. isUploading,
  255. layout,
  256. media,
  257. onCancelReply,
  258. onChangeAdvancedOption,
  259. onChangeDescription,
  260. onChangeSensitivity,
  261. onChangeSpoilerness,
  262. onChangeText,
  263. onChangeVisibility,
  264. onClearSuggestions,
  265. onCloseModal,
  266. onFetchSuggestions,
  267. onOpenActionsModal,
  268. onOpenDoodleModal,
  269. onUndoUpload,
  270. onUpload,
  271. privacy,
  272. progress,
  273. replyAccount,
  274. replyContent,
  275. resetFileKey,
  276. sensitive,
  277. showSearch,
  278. sideArm,
  279. spoiler,
  280. spoilerText,
  281. suggestions,
  282. text,
  283. } = this.props;
  284. let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia);
  285. return (
  286. <div className='composer'>
  287. <ComposerSpoiler
  288. hidden={!spoiler}
  289. intl={intl}
  290. onChange={handleChangeSpoiler}
  291. onSubmit={handleSubmit}
  292. text={spoilerText}
  293. />
  294. {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
  295. {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
  296. {replyContent ? (
  297. <ComposerReply
  298. account={replyAccount}
  299. content={replyContent}
  300. intl={intl}
  301. onCancel={onCancelReply}
  302. />
  303. ) : null}
  304. <ComposerTextarea
  305. advancedOptions={advancedOptions}
  306. autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
  307. disabled={isSubmitting}
  308. intl={intl}
  309. onChange={onChangeText}
  310. onPaste={onUpload}
  311. onPickEmoji={handleEmoji}
  312. onSubmit={handleSubmit}
  313. onSuggestionsClearRequested={onClearSuggestions}
  314. onSuggestionsFetchRequested={onFetchSuggestions}
  315. onSuggestionSelected={handleSelect}
  316. ref={handleRefTextarea}
  317. suggestions={suggestions}
  318. value={text}
  319. />
  320. {isUploading || media && media.size ? (
  321. <ComposerUploadForm
  322. intl={intl}
  323. media={media}
  324. onChangeDescription={onChangeDescription}
  325. onRemove={onUndoUpload}
  326. progress={progress}
  327. uploading={isUploading}
  328. />
  329. ) : null}
  330. <ComposerOptions
  331. acceptContentTypes={acceptContentTypes}
  332. advancedOptions={advancedOptions}
  333. disabled={isSubmitting}
  334. full={media ? media.size >= 4 || media.some(
  335. item => item.get('type') === 'video'
  336. ) : false}
  337. hasMedia={media && !!media.size}
  338. intl={intl}
  339. onChangeAdvancedOption={onChangeAdvancedOption}
  340. onChangeSensitivity={onChangeSensitivity}
  341. onChangeVisibility={onChangeVisibility}
  342. onDoodleOpen={onOpenDoodleModal}
  343. onModalClose={onCloseModal}
  344. onModalOpen={onOpenActionsModal}
  345. onToggleSpoiler={onChangeSpoilerness}
  346. onUpload={onUpload}
  347. privacy={privacy}
  348. resetFileKey={resetFileKey}
  349. sensitive={sensitive}
  350. spoiler={spoiler}
  351. />
  352. <ComposerPublisher
  353. countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
  354. disabled={disabledButton}
  355. intl={intl}
  356. onSecondarySubmit={handleSecondarySubmit}
  357. onSubmit={handleSubmit}
  358. privacy={privacy}
  359. sideArm={sideArm}
  360. />
  361. </div>
  362. );
  363. }
  364. }
  365. // Props.
  366. Composer.propTypes = {
  367. intl: PropTypes.object.isRequired,
  368. // State props.
  369. acceptContentTypes: PropTypes.string,
  370. advancedOptions: ImmutablePropTypes.map,
  371. amUnlocked: PropTypes.bool,
  372. focusDate: PropTypes.instanceOf(Date),
  373. isSubmitting: PropTypes.bool,
  374. isUploading: PropTypes.bool,
  375. layout: PropTypes.string,
  376. media: ImmutablePropTypes.list,
  377. preselectDate: PropTypes.instanceOf(Date),
  378. privacy: PropTypes.string,
  379. progress: PropTypes.number,
  380. replyAccount: PropTypes.string,
  381. replyContent: PropTypes.string,
  382. resetFileKey: PropTypes.number,
  383. sideArm: PropTypes.string,
  384. sensitive: PropTypes.bool,
  385. showSearch: PropTypes.bool,
  386. spoiler: PropTypes.bool,
  387. spoilerText: PropTypes.string,
  388. suggestionToken: PropTypes.string,
  389. suggestions: ImmutablePropTypes.list,
  390. text: PropTypes.string,
  391. // Dispatch props.
  392. onCancelReply: PropTypes.func,
  393. onChangeAdvancedOption: PropTypes.func,
  394. onChangeDescription: PropTypes.func,
  395. onChangeSensitivity: PropTypes.func,
  396. onChangeSpoilerText: PropTypes.func,
  397. onChangeSpoilerness: PropTypes.func,
  398. onChangeText: PropTypes.func,
  399. onChangeVisibility: PropTypes.func,
  400. onClearSuggestions: PropTypes.func,
  401. onCloseModal: PropTypes.func,
  402. onFetchSuggestions: PropTypes.func,
  403. onInsertEmoji: PropTypes.func,
  404. onMount: PropTypes.func,
  405. onOpenActionsModal: PropTypes.func,
  406. onOpenDoodleModal: PropTypes.func,
  407. onSelectSuggestion: PropTypes.func,
  408. onSubmit: PropTypes.func,
  409. onUndoUpload: PropTypes.func,
  410. onUnmount: PropTypes.func,
  411. onUpload: PropTypes.func,
  412. anyMedia: PropTypes.bool,
  413. };
  414. // Connecting and export.
  415. export { Composer as WrappedComponent };
  416. export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);