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.

578 lines
18 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
  1. // Package imports.
  2. import PropTypes from 'prop-types';
  3. import React from 'react';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import { defineMessages } from 'react-intl';
  6. const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i;
  7. // Actions.
  8. import {
  9. cancelReplyCompose,
  10. changeCompose,
  11. changeComposeAdvancedOption,
  12. changeComposeSensitivity,
  13. changeComposeSpoilerText,
  14. changeComposeSpoilerness,
  15. changeComposeVisibility,
  16. changeUploadCompose,
  17. clearComposeSuggestions,
  18. fetchComposeSuggestions,
  19. insertEmojiCompose,
  20. mountCompose,
  21. selectComposeSuggestion,
  22. submitCompose,
  23. undoUploadCompose,
  24. unmountCompose,
  25. uploadCompose,
  26. } from 'flavours/glitch/actions/compose';
  27. import {
  28. closeModal,
  29. openModal,
  30. } from 'flavours/glitch/actions/modal';
  31. import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
  32. // Components.
  33. import ComposerOptions from './options';
  34. import ComposerPublisher from './publisher';
  35. import ComposerReply from './reply';
  36. import ComposerSpoiler from './spoiler';
  37. import ComposerTextarea from './textarea';
  38. import ComposerUploadForm from './upload_form';
  39. import ComposerWarning from './warning';
  40. import ComposerHashtagWarning from './hashtag_warning';
  41. import ComposerDirectWarning from './direct_warning';
  42. // Utils.
  43. import { countableText } from 'flavours/glitch/util/counter';
  44. import { me } from 'flavours/glitch/util/initial_state';
  45. import { isMobile } from 'flavours/glitch/util/is_mobile';
  46. import { assignHandlers } from 'flavours/glitch/util/react_helpers';
  47. import { wrap } from 'flavours/glitch/util/redux_helpers';
  48. import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
  49. const messages = defineMessages({
  50. missingDescriptionMessage: { id: 'confirmations.missing_media_description.message',
  51. defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
  52. missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
  53. defaultMessage: 'Send anyway' },
  54. });
  55. // State mapping.
  56. function mapStateToProps (state) {
  57. const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
  58. const inReplyTo = state.getIn(['compose', 'in_reply_to']);
  59. const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
  60. const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
  61. const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
  62. let sideArmPrivacy = null;
  63. switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
  64. case 'copy':
  65. sideArmPrivacy = replyPrivacy;
  66. break;
  67. case 'restrict':
  68. sideArmPrivacy = sideArmRestrictedPrivacy;
  69. break;
  70. }
  71. sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
  72. return {
  73. acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
  74. advancedOptions: state.getIn(['compose', 'advanced_options']),
  75. amUnlocked: !state.getIn(['accounts', me, 'locked']),
  76. focusDate: state.getIn(['compose', 'focusDate']),
  77. caretPosition: state.getIn(['compose', 'caretPosition']),
  78. isSubmitting: state.getIn(['compose', 'is_submitting']),
  79. isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
  80. isUploading: state.getIn(['compose', 'is_uploading']),
  81. layout: state.getIn(['local_settings', 'layout']),
  82. media: state.getIn(['compose', 'media_attachments']),
  83. preselectDate: state.getIn(['compose', 'preselectDate']),
  84. privacy: state.getIn(['compose', 'privacy']),
  85. progress: state.getIn(['compose', 'progress']),
  86. inReplyTo: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
  87. replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
  88. replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
  89. resetFileKey: state.getIn(['compose', 'resetFileKey']),
  90. sideArm: sideArmPrivacy,
  91. sensitive: state.getIn(['compose', 'sensitive']),
  92. showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
  93. spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
  94. spoilerText: state.getIn(['compose', 'spoiler_text']),
  95. suggestionToken: state.getIn(['compose', 'suggestion_token']),
  96. suggestions: state.getIn(['compose', 'suggestions']),
  97. text: state.getIn(['compose', 'text']),
  98. anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
  99. spoilersAlwaysOn: spoilersAlwaysOn,
  100. mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
  101. preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
  102. };
  103. };
  104. // Dispatch mapping.
  105. const mapDispatchToProps = (dispatch, { intl }) => ({
  106. onCancelReply() {
  107. dispatch(cancelReplyCompose());
  108. },
  109. onChangeAdvancedOption(option, value) {
  110. dispatch(changeComposeAdvancedOption(option, value));
  111. },
  112. onChangeDescription(id, description) {
  113. dispatch(changeUploadCompose(id, { description }));
  114. },
  115. onChangeSensitivity() {
  116. dispatch(changeComposeSensitivity());
  117. },
  118. onChangeSpoilerText(text) {
  119. dispatch(changeComposeSpoilerText(text));
  120. },
  121. onChangeSpoilerness() {
  122. dispatch(changeComposeSpoilerness());
  123. },
  124. onChangeText(text) {
  125. dispatch(changeCompose(text));
  126. },
  127. onChangeVisibility(value) {
  128. dispatch(changeComposeVisibility(value));
  129. },
  130. onClearSuggestions() {
  131. dispatch(clearComposeSuggestions());
  132. },
  133. onCloseModal() {
  134. dispatch(closeModal());
  135. },
  136. onFetchSuggestions(token) {
  137. dispatch(fetchComposeSuggestions(token));
  138. },
  139. onInsertEmoji(position, emoji) {
  140. dispatch(insertEmojiCompose(position, emoji));
  141. },
  142. onMount() {
  143. dispatch(mountCompose());
  144. },
  145. onOpenActionsModal(props) {
  146. dispatch(openModal('ACTIONS', props));
  147. },
  148. onOpenDoodleModal() {
  149. dispatch(openModal('DOODLE', { noEsc: true }));
  150. },
  151. onOpenFocalPointModal(id) {
  152. dispatch(openModal('FOCAL_POINT', { id }));
  153. },
  154. onSelectSuggestion(position, token, suggestion) {
  155. dispatch(selectComposeSuggestion(position, token, suggestion));
  156. },
  157. onMediaDescriptionConfirm(routerHistory) {
  158. dispatch(openModal('CONFIRM', {
  159. message: intl.formatMessage(messages.missingDescriptionMessage),
  160. confirm: intl.formatMessage(messages.missingDescriptionConfirm),
  161. onConfirm: () => dispatch(submitCompose(routerHistory)),
  162. onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
  163. }));
  164. },
  165. onSubmit(routerHistory) {
  166. dispatch(submitCompose(routerHistory));
  167. },
  168. onUndoUpload(id) {
  169. dispatch(undoUploadCompose(id));
  170. },
  171. onUnmount() {
  172. dispatch(unmountCompose());
  173. },
  174. onUpload(files) {
  175. dispatch(uploadCompose(files));
  176. },
  177. });
  178. // Handlers.
  179. const handlers = {
  180. // Changes the text value of the spoiler.
  181. handleChangeSpoiler ({ target: { value } }) {
  182. const { onChangeSpoilerText } = this.props;
  183. if (onChangeSpoilerText) {
  184. onChangeSpoilerText(value);
  185. }
  186. },
  187. // Inserts an emoji at the caret.
  188. handleEmoji (data) {
  189. const { textarea: { selectionStart } } = this;
  190. const { onInsertEmoji } = this.props;
  191. if (onInsertEmoji) {
  192. onInsertEmoji(selectionStart, data);
  193. }
  194. },
  195. // Handles the secondary submit button.
  196. handleSecondarySubmit () {
  197. const { handleSubmit } = this.handlers;
  198. const {
  199. onChangeVisibility,
  200. sideArm,
  201. } = this.props;
  202. if (sideArm !== 'none' && onChangeVisibility) {
  203. onChangeVisibility(sideArm);
  204. }
  205. handleSubmit();
  206. },
  207. // Selects a suggestion from the autofill.
  208. handleSelect (tokenStart, token, value) {
  209. const { onSelectSuggestion } = this.props;
  210. if (onSelectSuggestion) {
  211. onSelectSuggestion(tokenStart, token, value);
  212. }
  213. },
  214. // Submits the status.
  215. handleSubmit () {
  216. const { textarea: { value }, uploadForm } = this;
  217. const {
  218. onChangeText,
  219. onSubmit,
  220. isSubmitting,
  221. isChangingUpload,
  222. isUploading,
  223. media,
  224. anyMedia,
  225. text,
  226. mediaDescriptionConfirmation,
  227. onMediaDescriptionConfirm,
  228. } = this.props;
  229. // If something changes inside the textarea, then we update the
  230. // state before submitting.
  231. if (onChangeText && text !== value) {
  232. onChangeText(value);
  233. }
  234. // Submit disabled:
  235. if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
  236. return;
  237. }
  238. // Submit unless there are media with missing descriptions
  239. if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
  240. const firstWithoutDescription = media.findIndex(item => !item.get('description'));
  241. if (uploadForm) {
  242. const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
  243. if (inputs.length == media.size && firstWithoutDescription !== -1) {
  244. inputs[firstWithoutDescription].focus();
  245. }
  246. }
  247. onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
  248. } else if (onSubmit) {
  249. onSubmit(this.context.router ? this.context.router.history : null);
  250. }
  251. },
  252. // Sets a reference to the upload form.
  253. handleRefUploadForm (uploadFormComponent) {
  254. this.uploadForm = uploadFormComponent;
  255. },
  256. // Sets a reference to the textarea.
  257. handleRefTextarea (textareaComponent) {
  258. if (textareaComponent) {
  259. this.textarea = textareaComponent.textarea;
  260. }
  261. },
  262. // Sets a reference to the CW field.
  263. handleRefSpoilerText (spoilerComponent) {
  264. if (spoilerComponent) {
  265. this.spoilerText = spoilerComponent.spoilerText;
  266. }
  267. }
  268. };
  269. // The component.
  270. class Composer extends React.Component {
  271. // Constructor.
  272. constructor (props) {
  273. super(props);
  274. assignHandlers(this, handlers);
  275. // Instance variables.
  276. this.textarea = null;
  277. this.spoilerText = null;
  278. }
  279. // Tells our state the composer has been mounted.
  280. componentDidMount () {
  281. const { onMount } = this.props;
  282. if (onMount) {
  283. onMount();
  284. }
  285. }
  286. // Tells our state the composer has been unmounted.
  287. componentWillUnmount () {
  288. const { onUnmount } = this.props;
  289. if (onUnmount) {
  290. onUnmount();
  291. }
  292. }
  293. // This statement does several things:
  294. // - If we're beginning a reply, and,
  295. // - Replying to zero or one users, places the cursor at the end
  296. // of the textbox.
  297. // - Replying to more than one user, selects any usernames past
  298. // the first; this provides a convenient shortcut to drop
  299. // everyone else from the conversation.
  300. componentDidUpdate (prevProps) {
  301. const {
  302. textarea,
  303. spoilerText,
  304. } = this;
  305. const {
  306. focusDate,
  307. caretPosition,
  308. isSubmitting,
  309. preselectDate,
  310. text,
  311. preselectOnReply,
  312. } = this.props;
  313. let selectionEnd, selectionStart;
  314. // Caret/selection handling.
  315. if (focusDate !== prevProps.focusDate) {
  316. switch (true) {
  317. case preselectDate !== prevProps.preselectDate && preselectOnReply:
  318. selectionStart = text.search(/\s/) + 1;
  319. selectionEnd = text.length;
  320. break;
  321. case !isNaN(caretPosition) && caretPosition !== null:
  322. selectionStart = selectionEnd = caretPosition;
  323. break;
  324. default:
  325. selectionStart = selectionEnd = text.length;
  326. }
  327. if (textarea) {
  328. textarea.setSelectionRange(selectionStart, selectionEnd);
  329. textarea.focus();
  330. textarea.scrollIntoView();
  331. }
  332. // Refocuses the textarea after submitting.
  333. } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
  334. textarea.focus();
  335. } else if (this.props.spoiler !== prevProps.spoiler) {
  336. if (this.props.spoiler) {
  337. if (spoilerText) {
  338. spoilerText.focus();
  339. }
  340. } else {
  341. if (textarea) {
  342. textarea.focus();
  343. }
  344. }
  345. }
  346. }
  347. render () {
  348. const {
  349. handleChangeSpoiler,
  350. handleEmoji,
  351. handleSecondarySubmit,
  352. handleSelect,
  353. handleSubmit,
  354. handleRefUploadForm,
  355. handleRefTextarea,
  356. handleRefSpoilerText,
  357. } = this.handlers;
  358. const {
  359. acceptContentTypes,
  360. advancedOptions,
  361. amUnlocked,
  362. anyMedia,
  363. intl,
  364. isSubmitting,
  365. isChangingUpload,
  366. isUploading,
  367. layout,
  368. media,
  369. onCancelReply,
  370. onChangeAdvancedOption,
  371. onChangeDescription,
  372. onChangeSensitivity,
  373. onChangeSpoilerness,
  374. onChangeText,
  375. onChangeVisibility,
  376. onClearSuggestions,
  377. onCloseModal,
  378. onFetchSuggestions,
  379. onOpenActionsModal,
  380. onOpenDoodleModal,
  381. onOpenFocalPointModal,
  382. onUndoUpload,
  383. onUpload,
  384. privacy,
  385. progress,
  386. inReplyTo,
  387. resetFileKey,
  388. sensitive,
  389. showSearch,
  390. sideArm,
  391. spoiler,
  392. spoilerText,
  393. suggestions,
  394. text,
  395. spoilersAlwaysOn,
  396. } = this.props;
  397. let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
  398. return (
  399. <div className='composer'>
  400. {privacy === 'direct' ? <ComposerDirectWarning /> : null}
  401. {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
  402. {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
  403. {inReplyTo && (
  404. <ComposerReply
  405. status={inReplyTo}
  406. intl={intl}
  407. onCancel={onCancelReply}
  408. />
  409. )}
  410. <ComposerSpoiler
  411. hidden={!spoiler}
  412. intl={intl}
  413. onChange={handleChangeSpoiler}
  414. onSubmit={handleSubmit}
  415. onSecondarySubmit={handleSecondarySubmit}
  416. text={spoilerText}
  417. ref={handleRefSpoilerText}
  418. />
  419. <ComposerTextarea
  420. advancedOptions={advancedOptions}
  421. autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
  422. disabled={isSubmitting}
  423. intl={intl}
  424. onChange={onChangeText}
  425. onPaste={onUpload}
  426. onPickEmoji={handleEmoji}
  427. onSubmit={handleSubmit}
  428. onSecondarySubmit={handleSecondarySubmit}
  429. onSuggestionsClearRequested={onClearSuggestions}
  430. onSuggestionsFetchRequested={onFetchSuggestions}
  431. onSuggestionSelected={handleSelect}
  432. ref={handleRefTextarea}
  433. suggestions={suggestions}
  434. value={text}
  435. />
  436. {isUploading || media && media.size ? (
  437. <ComposerUploadForm
  438. intl={intl}
  439. media={media}
  440. onChangeDescription={onChangeDescription}
  441. onOpenFocalPointModal={onOpenFocalPointModal}
  442. onRemove={onUndoUpload}
  443. progress={progress}
  444. uploading={isUploading}
  445. handleRef={handleRefUploadForm}
  446. />
  447. ) : null}
  448. <ComposerOptions
  449. acceptContentTypes={acceptContentTypes}
  450. advancedOptions={advancedOptions}
  451. disabled={isSubmitting}
  452. full={media ? media.size >= 4 || media.some(
  453. item => item.get('type') === 'video'
  454. ) : false}
  455. hasMedia={media && !!media.size}
  456. intl={intl}
  457. onChangeAdvancedOption={onChangeAdvancedOption}
  458. onChangeSensitivity={onChangeSensitivity}
  459. onChangeVisibility={onChangeVisibility}
  460. onDoodleOpen={onOpenDoodleModal}
  461. onModalClose={onCloseModal}
  462. onModalOpen={onOpenActionsModal}
  463. onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
  464. onUpload={onUpload}
  465. privacy={privacy}
  466. resetFileKey={resetFileKey}
  467. sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
  468. spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
  469. />
  470. <ComposerPublisher
  471. countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
  472. disabled={disabledButton}
  473. intl={intl}
  474. onSecondarySubmit={handleSecondarySubmit}
  475. onSubmit={handleSubmit}
  476. privacy={privacy}
  477. sideArm={sideArm}
  478. />
  479. </div>
  480. );
  481. }
  482. }
  483. // Props.
  484. Composer.propTypes = {
  485. intl: PropTypes.object.isRequired,
  486. // State props.
  487. acceptContentTypes: PropTypes.string,
  488. advancedOptions: ImmutablePropTypes.map,
  489. amUnlocked: PropTypes.bool,
  490. focusDate: PropTypes.instanceOf(Date),
  491. caretPosition: PropTypes.number,
  492. isSubmitting: PropTypes.bool,
  493. isChangingUpload: PropTypes.bool,
  494. isUploading: PropTypes.bool,
  495. layout: PropTypes.string,
  496. media: ImmutablePropTypes.list,
  497. preselectDate: PropTypes.instanceOf(Date),
  498. privacy: PropTypes.string,
  499. progress: PropTypes.number,
  500. inReplyTo: ImmutablePropTypes.map,
  501. resetFileKey: PropTypes.number,
  502. sideArm: PropTypes.string,
  503. sensitive: PropTypes.bool,
  504. showSearch: PropTypes.bool,
  505. spoiler: PropTypes.bool,
  506. spoilerText: PropTypes.string,
  507. suggestionToken: PropTypes.string,
  508. suggestions: ImmutablePropTypes.list,
  509. text: PropTypes.string,
  510. anyMedia: PropTypes.bool,
  511. spoilersAlwaysOn: PropTypes.bool,
  512. mediaDescriptionConfirmation: PropTypes.bool,
  513. preselectOnReply: PropTypes.bool,
  514. // Dispatch props.
  515. onCancelReply: PropTypes.func,
  516. onChangeAdvancedOption: PropTypes.func,
  517. onChangeDescription: PropTypes.func,
  518. onChangeSensitivity: PropTypes.func,
  519. onChangeSpoilerText: PropTypes.func,
  520. onChangeSpoilerness: PropTypes.func,
  521. onChangeText: PropTypes.func,
  522. onChangeVisibility: PropTypes.func,
  523. onClearSuggestions: PropTypes.func,
  524. onCloseModal: PropTypes.func,
  525. onFetchSuggestions: PropTypes.func,
  526. onInsertEmoji: PropTypes.func,
  527. onMount: PropTypes.func,
  528. onOpenActionsModal: PropTypes.func,
  529. onOpenDoodleModal: PropTypes.func,
  530. onSelectSuggestion: PropTypes.func,
  531. onSubmit: PropTypes.func,
  532. onUndoUpload: PropTypes.func,
  533. onUnmount: PropTypes.func,
  534. onUpload: PropTypes.func,
  535. onMediaDescriptionConfirm: PropTypes.func,
  536. };
  537. Composer.contextTypes = {
  538. router: PropTypes.object,
  539. };
  540. // Connecting and export.
  541. export { Composer as WrappedComponent };
  542. export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);