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.

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