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