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.

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