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.

447 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. import ComposerDirectWarning from './direct_warning';
  40. // Utils.
  41. import { countableText } from 'flavours/glitch/util/counter';
  42. import { me } from 'flavours/glitch/util/initial_state';
  43. import { isMobile } from 'flavours/glitch/util/is_mobile';
  44. import { assignHandlers } from 'flavours/glitch/util/react_helpers';
  45. import { wrap } from 'flavours/glitch/util/redux_helpers';
  46. // State mapping.
  47. function mapStateToProps (state) {
  48. const inReplyTo = state.getIn(['compose', 'in_reply_to']);
  49. const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
  50. const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
  51. const sideArmPrivacy = (state.getIn(['local_settings', 'side_arm_reply_mode']) === 'copy' ? replyPrivacy : null) || sideArmBasePrivacy;
  52. return {
  53. acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
  54. advancedOptions: state.getIn(['compose', 'advanced_options']),
  55. amUnlocked: !state.getIn(['accounts', me, 'locked']),
  56. focusDate: state.getIn(['compose', 'focusDate']),
  57. caretPosition: state.getIn(['compose', 'caretPosition']),
  58. isSubmitting: state.getIn(['compose', 'is_submitting']),
  59. isUploading: state.getIn(['compose', 'is_uploading']),
  60. layout: state.getIn(['local_settings', 'layout']),
  61. media: state.getIn(['compose', 'media_attachments']),
  62. preselectDate: state.getIn(['compose', 'preselectDate']),
  63. privacy: state.getIn(['compose', 'privacy']),
  64. progress: state.getIn(['compose', 'progress']),
  65. replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
  66. replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
  67. resetFileKey: state.getIn(['compose', 'resetFileKey']),
  68. sideArm: sideArmPrivacy,
  69. sensitive: state.getIn(['compose', 'sensitive']),
  70. showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
  71. spoiler: state.getIn(['compose', 'spoiler']),
  72. spoilerText: state.getIn(['compose', 'spoiler_text']),
  73. suggestionToken: state.getIn(['compose', 'suggestion_token']),
  74. suggestions: state.getIn(['compose', 'suggestions']),
  75. text: state.getIn(['compose', 'text']),
  76. anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
  77. };
  78. };
  79. // Dispatch mapping.
  80. const mapDispatchToProps = {
  81. onCancelReply: cancelReplyCompose,
  82. onChangeAdvancedOption: changeComposeAdvancedOption,
  83. onChangeDescription: changeUploadCompose,
  84. onChangeSensitivity: changeComposeSensitivity,
  85. onChangeSpoilerText: changeComposeSpoilerText,
  86. onChangeSpoilerness: changeComposeSpoilerness,
  87. onChangeText: changeCompose,
  88. onChangeVisibility: changeComposeVisibility,
  89. onClearSuggestions: clearComposeSuggestions,
  90. onCloseModal: closeModal,
  91. onFetchSuggestions: fetchComposeSuggestions,
  92. onInsertEmoji: insertEmojiCompose,
  93. onMount: mountCompose,
  94. onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
  95. onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
  96. onSelectSuggestion: selectComposeSuggestion,
  97. onSubmit: submitCompose,
  98. onUndoUpload: undoUploadCompose,
  99. onUnmount: unmountCompose,
  100. onUpload: uploadCompose,
  101. };
  102. // Handlers.
  103. const handlers = {
  104. // Changes the text value of the spoiler.
  105. handleChangeSpoiler ({ target: { value } }) {
  106. const { onChangeSpoilerText } = this.props;
  107. if (onChangeSpoilerText) {
  108. onChangeSpoilerText(value);
  109. }
  110. },
  111. // Inserts an emoji at the caret.
  112. handleEmoji (data) {
  113. const { textarea: { selectionStart } } = this;
  114. const { onInsertEmoji } = this.props;
  115. if (onInsertEmoji) {
  116. onInsertEmoji(selectionStart, data);
  117. }
  118. },
  119. // Handles the secondary submit button.
  120. handleSecondarySubmit () {
  121. const { handleSubmit } = this.handlers;
  122. const {
  123. onChangeVisibility,
  124. sideArm,
  125. } = this.props;
  126. if (sideArm !== 'none' && onChangeVisibility) {
  127. onChangeVisibility(sideArm);
  128. }
  129. handleSubmit();
  130. },
  131. // Selects a suggestion from the autofill.
  132. handleSelect (tokenStart, token, value) {
  133. const { onSelectSuggestion } = this.props;
  134. if (onSelectSuggestion) {
  135. onSelectSuggestion(tokenStart, token, value);
  136. }
  137. },
  138. // Submits the status.
  139. handleSubmit () {
  140. const { textarea: { value } } = this;
  141. const {
  142. onChangeText,
  143. onSubmit,
  144. isSubmitting,
  145. isUploading,
  146. anyMedia,
  147. text,
  148. } = this.props;
  149. // If something changes inside the textarea, then we update the
  150. // state before submitting.
  151. if (onChangeText && text !== value) {
  152. onChangeText(value);
  153. }
  154. // Submit disabled:
  155. if (isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia)) {
  156. return;
  157. }
  158. // Submits the status.
  159. if (onSubmit) {
  160. onSubmit();
  161. }
  162. },
  163. // Sets a reference to the textarea.
  164. handleRefTextarea (textareaComponent) {
  165. if (textareaComponent) {
  166. this.textarea = textareaComponent.textarea;
  167. }
  168. },
  169. };
  170. // The component.
  171. class Composer extends React.Component {
  172. // Constructor.
  173. constructor (props) {
  174. super(props);
  175. assignHandlers(this, handlers);
  176. // Instance variables.
  177. this.textarea = null;
  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. componentDidUpdate (prevProps) {
  201. const {
  202. textarea,
  203. } = this;
  204. const {
  205. focusDate,
  206. caretPosition,
  207. isSubmitting,
  208. preselectDate,
  209. text,
  210. } = this.props;
  211. let selectionEnd, selectionStart;
  212. // Caret/selection handling.
  213. if (focusDate !== prevProps.focusDate) {
  214. switch (true) {
  215. case preselectDate !== prevProps.preselectDate:
  216. selectionStart = text.search(/\s/) + 1;
  217. selectionEnd = text.length;
  218. break;
  219. case !isNaN(caretPosition) && caretPosition !== null:
  220. selectionStart = selectionEnd = caretPosition;
  221. break;
  222. default:
  223. selectionStart = selectionEnd = text.length;
  224. }
  225. if (textarea) {
  226. textarea.setSelectionRange(selectionStart, selectionEnd);
  227. textarea.focus();
  228. }
  229. // Refocuses the textarea after submitting.
  230. } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
  231. textarea.focus();
  232. }
  233. }
  234. render () {
  235. const {
  236. handleChangeSpoiler,
  237. handleEmoji,
  238. handleSecondarySubmit,
  239. handleSelect,
  240. handleSubmit,
  241. handleRefTextarea,
  242. } = this.handlers;
  243. const {
  244. acceptContentTypes,
  245. advancedOptions,
  246. amUnlocked,
  247. anyMedia,
  248. intl,
  249. isSubmitting,
  250. isUploading,
  251. layout,
  252. media,
  253. onCancelReply,
  254. onChangeAdvancedOption,
  255. onChangeDescription,
  256. onChangeSensitivity,
  257. onChangeSpoilerness,
  258. onChangeText,
  259. onChangeVisibility,
  260. onClearSuggestions,
  261. onCloseModal,
  262. onFetchSuggestions,
  263. onOpenActionsModal,
  264. onOpenDoodleModal,
  265. onUndoUpload,
  266. onUpload,
  267. privacy,
  268. progress,
  269. replyAccount,
  270. replyContent,
  271. resetFileKey,
  272. sensitive,
  273. showSearch,
  274. sideArm,
  275. spoiler,
  276. spoilerText,
  277. suggestions,
  278. text,
  279. } = this.props;
  280. let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia);
  281. return (
  282. <div className='composer'>
  283. <ComposerSpoiler
  284. hidden={!spoiler}
  285. intl={intl}
  286. onChange={handleChangeSpoiler}
  287. onSubmit={handleSubmit}
  288. text={spoilerText}
  289. />
  290. {privacy === 'direct' ? <ComposerDirectWarning /> : null}
  291. {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
  292. {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
  293. {replyContent !== null && (
  294. <ComposerReply
  295. account={replyAccount}
  296. content={replyContent}
  297. intl={intl}
  298. onCancel={onCancelReply}
  299. />
  300. )}
  301. <ComposerTextarea
  302. advancedOptions={advancedOptions}
  303. autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
  304. disabled={isSubmitting}
  305. intl={intl}
  306. onChange={onChangeText}
  307. onPaste={onUpload}
  308. onPickEmoji={handleEmoji}
  309. onSubmit={handleSubmit}
  310. onSecondarySubmit={handleSecondarySubmit}
  311. onSuggestionsClearRequested={onClearSuggestions}
  312. onSuggestionsFetchRequested={onFetchSuggestions}
  313. onSuggestionSelected={handleSelect}
  314. ref={handleRefTextarea}
  315. suggestions={suggestions}
  316. value={text}
  317. />
  318. {isUploading || media && media.size ? (
  319. <ComposerUploadForm
  320. intl={intl}
  321. media={media}
  322. onChangeDescription={onChangeDescription}
  323. onRemove={onUndoUpload}
  324. progress={progress}
  325. uploading={isUploading}
  326. />
  327. ) : null}
  328. <ComposerOptions
  329. acceptContentTypes={acceptContentTypes}
  330. advancedOptions={advancedOptions}
  331. disabled={isSubmitting}
  332. full={media ? media.size >= 4 || media.some(
  333. item => item.get('type') === 'video'
  334. ) : false}
  335. hasMedia={media && !!media.size}
  336. intl={intl}
  337. onChangeAdvancedOption={onChangeAdvancedOption}
  338. onChangeSensitivity={onChangeSensitivity}
  339. onChangeVisibility={onChangeVisibility}
  340. onDoodleOpen={onOpenDoodleModal}
  341. onModalClose={onCloseModal}
  342. onModalOpen={onOpenActionsModal}
  343. onToggleSpoiler={onChangeSpoilerness}
  344. onUpload={onUpload}
  345. privacy={privacy}
  346. resetFileKey={resetFileKey}
  347. sensitive={sensitive}
  348. spoiler={spoiler}
  349. />
  350. <ComposerPublisher
  351. countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
  352. disabled={disabledButton}
  353. intl={intl}
  354. onSecondarySubmit={handleSecondarySubmit}
  355. onSubmit={handleSubmit}
  356. privacy={privacy}
  357. sideArm={sideArm}
  358. />
  359. </div>
  360. );
  361. }
  362. }
  363. // Props.
  364. Composer.propTypes = {
  365. intl: PropTypes.object.isRequired,
  366. // State props.
  367. acceptContentTypes: PropTypes.string,
  368. advancedOptions: ImmutablePropTypes.map,
  369. amUnlocked: PropTypes.bool,
  370. focusDate: PropTypes.instanceOf(Date),
  371. caretPosition: PropTypes.number,
  372. isSubmitting: PropTypes.bool,
  373. isUploading: PropTypes.bool,
  374. layout: PropTypes.string,
  375. media: ImmutablePropTypes.list,
  376. preselectDate: PropTypes.instanceOf(Date),
  377. privacy: PropTypes.string,
  378. progress: PropTypes.number,
  379. replyAccount: PropTypes.string,
  380. replyContent: PropTypes.string,
  381. resetFileKey: PropTypes.number,
  382. sideArm: PropTypes.string,
  383. sensitive: PropTypes.bool,
  384. showSearch: PropTypes.bool,
  385. spoiler: PropTypes.bool,
  386. spoilerText: PropTypes.string,
  387. suggestionToken: PropTypes.string,
  388. suggestions: ImmutablePropTypes.list,
  389. text: PropTypes.string,
  390. // Dispatch props.
  391. onCancelReply: PropTypes.func,
  392. onChangeAdvancedOption: PropTypes.func,
  393. onChangeDescription: PropTypes.func,
  394. onChangeSensitivity: PropTypes.func,
  395. onChangeSpoilerText: PropTypes.func,
  396. onChangeSpoilerness: PropTypes.func,
  397. onChangeText: PropTypes.func,
  398. onChangeVisibility: PropTypes.func,
  399. onClearSuggestions: PropTypes.func,
  400. onCloseModal: PropTypes.func,
  401. onFetchSuggestions: PropTypes.func,
  402. onInsertEmoji: PropTypes.func,
  403. onMount: PropTypes.func,
  404. onOpenActionsModal: PropTypes.func,
  405. onOpenDoodleModal: PropTypes.func,
  406. onSelectSuggestion: PropTypes.func,
  407. onSubmit: PropTypes.func,
  408. onUndoUpload: PropTypes.func,
  409. onUnmount: PropTypes.func,
  410. onUpload: PropTypes.func,
  411. anyMedia: PropTypes.bool,
  412. };
  413. // Connecting and export.
  414. export { Composer as WrappedComponent };
  415. export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);