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.

733 lines
20 KiB

  1. /*
  2. `<Status>`
  3. ==========
  4. Original file by @gargron@mastodon.social et al as part of
  5. tootsuite/mastodon. *Heavily* rewritten (and documented!) by
  6. @kibi@glitch.social as a part of glitch-soc/mastodon. The following
  7. features have been added:
  8. - Better separating the "guts" of statuses from their wrapper(s)
  9. - Collapsing statuses
  10. - Moving images inside of CWs
  11. A number of aspects of this original file have been split off into
  12. their own components for better maintainance; for these, see:
  13. - <StatusHeader>
  14. - <StatusPrepend>
  15. And, of course, the other <Status>-related components as well.
  16. */
  17. /* * * * */
  18. /*
  19. Imports:
  20. --------
  21. */
  22. // Package imports //
  23. import React from 'react';
  24. import PropTypes from 'prop-types';
  25. import ImmutablePropTypes from 'react-immutable-proptypes';
  26. import ImmutablePureComponent from 'react-immutable-pure-component';
  27. // Mastodon imports //
  28. import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
  29. // Our imports //
  30. import StatusPrepend from './prepend';
  31. import StatusHeader from './header';
  32. import StatusContent from './content';
  33. import StatusActionBar from './action_bar';
  34. import StatusGallery from './gallery';
  35. import StatusPlayer from './player';
  36. /* * * * */
  37. /*
  38. The `<Status>` component:
  39. -------------------------
  40. The `<Status>` component is a container for statuses. It consists of a
  41. few parts:
  42. - The `<StatusPrepend>`, which contains tangential information about
  43. the status, such as who reblogged it.
  44. - The `<StatusHeader>`, which contains the avatar and username of the
  45. status author, as well as a media icon and the "collapse" toggle.
  46. - The `<StatusContent>`, which contains the content of the status.
  47. - The `<StatusActionBar>`, which provides actions to be performed
  48. on statuses, like reblogging or sending a reply.
  49. ### Context
  50. - __`router` (`PropTypes.object`) :__
  51. We need to get our router from the surrounding React context.
  52. ### Props
  53. - __`id` (`PropTypes.number`) :__
  54. The id of the status.
  55. - __`status` (`ImmutablePropTypes.map`) :__
  56. The status object, straight from the store.
  57. - __`account` (`ImmutablePropTypes.map`) :__
  58. Don't be confused by this one! This is **not** the account which
  59. posted the status, but the associated account with any further
  60. action (eg, a reblog or a favourite).
  61. - __`settings` (`ImmutablePropTypes.map`) :__
  62. These are our local settings, fetched from our store. We need this
  63. to determine how best to collapse our statuses, among other things.
  64. - __`me` (`PropTypes.number`) :__
  65. This is the id of the currently-signed-in user.
  66. - __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
  67. `onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
  68. `onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
  69. These are all functions passed through from the
  70. `<StatusContainer>`. We don't deal with them directly here.
  71. - __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
  72. These tell whether or not the user has modals activated for
  73. reblogging and deleting statuses. They are used by the `onReblog`
  74. and `onDelete` functions, but we don't deal with them here.
  75. - __`autoPlayGif` (`PropTypes.bool`) :__
  76. This tells the frontend whether or not to autoplay gifs!
  77. - __`muted` (`PropTypes.bool`) :__
  78. This has nothing to do with a user or conversation mute! "Muted" is
  79. what Mastodon internally calls the subdued look of statuses in the
  80. notifications column. This should be `true` for notifications, and
  81. `false` otherwise.
  82. - __`collapse` (`PropTypes.bool`) :__
  83. This prop signals a directive from a higher power to (un)collapse
  84. a status. Most of the time it should be `undefined`, in which case
  85. we do nothing.
  86. - __`prepend` (`PropTypes.string`) :__
  87. The type of prepend: `'reblogged_by'`, `'reblog'`, or
  88. `'favourite'`.
  89. - __`withDismiss` (`PropTypes.bool`) :__
  90. Whether or not the status can be dismissed. Used for notifications.
  91. - __`intersectionObserverWrapper` (`PropTypes.object`) :__
  92. This holds our intersection observer. In Mastodon parlance,
  93. an "intersection" is just when the status is viewable onscreen.
  94. ### State
  95. - __`isExpanded` :__
  96. Should be either `true`, `false`, or `null`. The meanings of
  97. these values are as follows:
  98. - __`true` :__ The status contains a CW and the CW is expanded.
  99. - __`false` :__ The status is collapsed.
  100. - __`null` :__ The status is not collapsed or expanded.
  101. - __`isIntersecting` :__
  102. This boolean tells us whether or not the status is currently
  103. onscreen.
  104. - __`isHidden` :__
  105. This boolean tells us if the status has been unrendered to save
  106. CPUs.
  107. */
  108. export default class Status extends ImmutablePureComponent {
  109. static contextTypes = {
  110. router : PropTypes.object,
  111. };
  112. static propTypes = {
  113. id : PropTypes.number,
  114. status : ImmutablePropTypes.map,
  115. account : ImmutablePropTypes.map,
  116. settings : ImmutablePropTypes.map,
  117. me : PropTypes.number,
  118. onFavourite : PropTypes.func,
  119. onReblog : PropTypes.func,
  120. onModalReblog : PropTypes.func,
  121. onDelete : PropTypes.func,
  122. onMention : PropTypes.func,
  123. onMute : PropTypes.func,
  124. onMuteConversation : PropTypes.func,
  125. onBlock : PropTypes.func,
  126. onReport : PropTypes.func,
  127. onOpenMedia : PropTypes.func,
  128. onOpenVideo : PropTypes.func,
  129. onDeleteNotification : PropTypes.func,
  130. reblogModal : PropTypes.bool,
  131. deleteModal : PropTypes.bool,
  132. autoPlayGif : PropTypes.bool,
  133. muted : PropTypes.bool,
  134. collapse : PropTypes.bool,
  135. prepend : PropTypes.string,
  136. withDismiss : PropTypes.bool,
  137. notificationId : PropTypes.number,
  138. intersectionObserverWrapper : PropTypes.object,
  139. };
  140. state = {
  141. isExpanded : null,
  142. isIntersecting : true,
  143. isHidden : false,
  144. }
  145. /*
  146. ### Implementation
  147. #### `updateOnProps` and `updateOnStates`.
  148. `updateOnProps` and `updateOnStates` tell the component when to update.
  149. We specify them explicitly because some of our props are dynamically=
  150. generated functions, which would otherwise always trigger an update.
  151. Of course, this means that if we add an important prop, we will need
  152. to remember to specify it here.
  153. */
  154. updateOnProps = [
  155. 'status',
  156. 'account',
  157. 'settings',
  158. 'prepend',
  159. 'me',
  160. 'boostModal',
  161. 'autoPlayGif',
  162. 'muted',
  163. 'collapse',
  164. ]
  165. updateOnStates = [
  166. 'isExpanded',
  167. ]
  168. /*
  169. #### `componentWillReceiveProps()`.
  170. If our settings have changed to disable collapsed statuses, then we
  171. need to make sure that we uncollapse every one. We do that by watching
  172. for changes to `settings.collapsed.enabled` in
  173. `componentWillReceiveProps()`.
  174. We also need to watch for changes on the `collapse` prop---if this
  175. changes to anything other than `undefined`, then we need to collapse or
  176. uncollapse our status accordingly.
  177. */
  178. componentWillReceiveProps (nextProps) {
  179. if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
  180. if (this.state.isExpanded === false) {
  181. this.setExpansion(null);
  182. }
  183. } else if (
  184. nextProps.collapse !== this.props.collapse &&
  185. nextProps.collapse !== undefined
  186. ) this.setExpansion(nextProps.collapse ? false : null);
  187. }
  188. /*
  189. #### `componentDidMount()`.
  190. When mounting, we just check to see if our status should be collapsed,
  191. and collapse it if so. We don't need to worry about whether collapsing
  192. is enabled here, because `setExpansion()` already takes that into
  193. account.
  194. The cases where a status should be collapsed are:
  195. - The `collapse` prop has been set to `true`
  196. - The user has decided in local settings to collapse all statuses.
  197. - The user has decided to collapse all notifications ('muted'
  198. statuses).
  199. - The user has decided to collapse long statuses and the status is
  200. over 400px (without media, or 650px with).
  201. - The status is a reply and the user has decided to collapse all
  202. replies.
  203. - The status contains media and the user has decided to collapse all
  204. statuses with media.
  205. We also start up our intersection observer to monitor our statuses.
  206. `componentMounted` lets us know that everything has been set up
  207. properly and our intersection observer is good to go.
  208. */
  209. componentDidMount () {
  210. const { node, handleIntersection } = this;
  211. const {
  212. status,
  213. settings,
  214. collapse,
  215. muted,
  216. id,
  217. intersectionObserverWrapper,
  218. } = this.props;
  219. const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
  220. if (
  221. collapse ||
  222. autoCollapseSettings.get('all') || (
  223. autoCollapseSettings.get('notifications') && muted
  224. ) || (
  225. autoCollapseSettings.get('lengthy') &&
  226. node.clientHeight > (
  227. status.get('media_attachments').size && !muted ? 650 : 400
  228. )
  229. ) || (
  230. autoCollapseSettings.get('replies') &&
  231. status.get('in_reply_to_id', null) !== null
  232. ) || (
  233. autoCollapseSettings.get('media') &&
  234. !(status.get('spoiler_text').length) &&
  235. status.get('media_attachments').size
  236. )
  237. ) this.setExpansion(false);
  238. if (!intersectionObserverWrapper) return;
  239. else intersectionObserverWrapper.observe(
  240. id,
  241. node,
  242. handleIntersection
  243. );
  244. this.componentMounted = true;
  245. }
  246. /*
  247. #### `shouldComponentUpdate()`.
  248. If the status is about to be both offscreen (not intersecting) and
  249. hidden, then we only need to update it if it's not that way currently.
  250. If the status is moving from offscreen to onscreen, then we *have* to
  251. re-render, so that we can unhide the element if necessary.
  252. If neither of these cases are true, we can leave it up to our
  253. `updateOnProps` and `updateOnStates` arrays.
  254. */
  255. shouldComponentUpdate (nextProps, nextState) {
  256. switch (true) {
  257. case !nextState.isIntersecting && nextState.isHidden:
  258. return this.state.isIntersecting || !this.state.isHidden;
  259. case nextState.isIntersecting && !this.state.isIntersecting:
  260. return true;
  261. default:
  262. return super.shouldComponentUpdate(nextProps, nextState);
  263. }
  264. }
  265. /*
  266. #### `componentDidUpdate()`.
  267. If our component is being rendered for any reason and an update has
  268. triggered, this will save its height.
  269. This is, frankly, a bit overkill, as the only instance when we
  270. actually *need* to update the height right now should be when the
  271. value of `isExpanded` has changed. But it makes for more readable
  272. code and prevents bugs in the future where the height isn't set
  273. properly after some change.
  274. */
  275. componentDidUpdate () {
  276. if (
  277. this.state.isIntersecting || !this.state.isHidden
  278. ) this.saveHeight();
  279. }
  280. /*
  281. #### `componentWillUnmount()`.
  282. If our component is about to unmount, then we'd better unset
  283. `this.componentMounted`.
  284. */
  285. componentWillUnmount () {
  286. this.componentMounted = false;
  287. }
  288. /*
  289. #### `handleIntersection()`.
  290. `handleIntersection()` either hides the status (if it is offscreen) or
  291. unhides it (if it is onscreen). It's called by
  292. `intersectionObserverWrapper.observe()`.
  293. If our status isn't intersecting, we schedule an idle task (using the
  294. aptly-named `scheduleIdleTask()`) to hide the status at the next
  295. available opportunity.
  296. tootsuite/mastodon left us with the following enlightening comment
  297. regarding this function:
  298. > Edge 15 doesn't support isIntersecting, but we can infer it
  299. It then implements a polyfill (intersectionRect.height > 0) which isn't
  300. actually sufficient. The short answer is, this behaviour isn't really
  301. supported on Edge but we can get kinda close.
  302. */
  303. handleIntersection = (entry) => {
  304. const isIntersecting = (
  305. typeof entry.isIntersecting === 'boolean' ?
  306. entry.isIntersecting :
  307. entry.intersectionRect.height > 0
  308. );
  309. this.setState(
  310. (prevState) => {
  311. if (prevState.isIntersecting && !isIntersecting) {
  312. scheduleIdleTask(this.hideIfNotIntersecting);
  313. }
  314. return {
  315. isIntersecting : isIntersecting,
  316. isHidden : false,
  317. };
  318. }
  319. );
  320. }
  321. /*
  322. #### `hideIfNotIntersecting()`.
  323. This function will hide the status if we're still not intersecting.
  324. Hiding the status means that it will just render an empty div instead
  325. of actual content, which saves RAMS and CPUs or some such.
  326. */
  327. hideIfNotIntersecting = () => {
  328. if (!this.componentMounted) return;
  329. this.setState(
  330. (prevState) => ({ isHidden: !prevState.isIntersecting })
  331. );
  332. }
  333. /*
  334. #### `saveHeight()`.
  335. `saveHeight()` saves the height of our status so that when whe hide it
  336. we preserve its dimensions. We only want to store our height, though,
  337. if our status has content (otherwise, it would imply that it is
  338. already hidden).
  339. */
  340. saveHeight = () => {
  341. if (this.node && this.node.children.length) {
  342. this.height = this.node.getBoundingClientRect().height;
  343. }
  344. }
  345. /*
  346. #### `setExpansion()`.
  347. `setExpansion()` sets the value of `isExpanded` in our state. It takes
  348. one argument, `value`, which gives the desired value for `isExpanded`.
  349. The default for this argument is `null`.
  350. `setExpansion()` automatically checks for us whether toot collapsing
  351. is enabled, so we don't have to.
  352. We use a `switch` statement to simplify our code.
  353. */
  354. setExpansion = (value) => {
  355. switch (true) {
  356. case value === undefined || value === null:
  357. this.setState({ isExpanded: null });
  358. break;
  359. case !value && this.props.settings.getIn(['collapsed', 'enabled']):
  360. this.setState({ isExpanded: false });
  361. break;
  362. case !!value:
  363. this.setState({ isExpanded: true });
  364. break;
  365. }
  366. }
  367. /*
  368. #### `handleRef()`.
  369. `handleRef()` just saves a reference to our status node to `this.node`.
  370. It also saves our height, in case the height of our node has changed.
  371. */
  372. handleRef = (node) => {
  373. this.node = node;
  374. this.saveHeight();
  375. }
  376. /*
  377. #### `parseClick()`.
  378. `parseClick()` takes a click event and responds appropriately.
  379. If our status is collapsed, then clicking on it should uncollapse it.
  380. If `Shift` is held, then clicking on it should collapse it.
  381. Otherwise, we open the url handed to us in `destination`, if
  382. applicable.
  383. */
  384. parseClick = (e, destination) => {
  385. const { router } = this.context;
  386. const { status } = this.props;
  387. const { isExpanded } = this.state;
  388. if (!router) return;
  389. if (destination === undefined) {
  390. destination = `/statuses/${
  391. status.getIn(['reblog', 'id'], status.get('id'))
  392. }`;
  393. }
  394. if (e.button === 0) {
  395. if (isExpanded === false) this.setExpansion(null);
  396. else if (e.shiftKey) {
  397. this.setExpansion(false);
  398. document.getSelection().removeAllRanges();
  399. } else router.history.push(destination);
  400. e.preventDefault();
  401. }
  402. }
  403. /*
  404. #### `render()`.
  405. `render()` actually puts our element on the screen. The particulars of
  406. this operation are further explained in the code below.
  407. */
  408. render () {
  409. const {
  410. parseClick,
  411. setExpansion,
  412. saveHeight,
  413. handleRef,
  414. } = this;
  415. const { router } = this.context;
  416. const {
  417. status,
  418. account,
  419. settings,
  420. collapsed,
  421. muted,
  422. prepend,
  423. intersectionObserverWrapper,
  424. onOpenVideo,
  425. onOpenMedia,
  426. autoPlayGif,
  427. ...other
  428. } = this.props;
  429. const { isExpanded, isIntersecting, isHidden } = this.state;
  430. let background = null;
  431. let attachments = null;
  432. let media = null;
  433. let mediaIcon = null;
  434. /*
  435. If we don't have a status, then we don't render anything.
  436. */
  437. if (status === null) {
  438. return null;
  439. }
  440. /*
  441. If our status is offscreen and hidden, then we render an empty <div> in
  442. its place. We fill it with "content" but note that opacity is set to 0.
  443. */
  444. if (!isIntersecting && isHidden) {
  445. return (
  446. <div
  447. ref={this.handleRef}
  448. data-id={status.get('id')}
  449. style={{
  450. height : `${this.height}px`,
  451. opacity : 0,
  452. overflow : 'hidden',
  453. }}
  454. >
  455. {
  456. status.getIn(['account', 'display_name']) ||
  457. status.getIn(['account', 'username'])
  458. }
  459. {status.get('content')}
  460. </div>
  461. );
  462. }
  463. /*
  464. If user backgrounds for collapsed statuses are enabled, then we
  465. initialize our background accordingly. This will only be rendered if
  466. the status is collapsed.
  467. */
  468. if (
  469. settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
  470. ) background = status.getIn(['account', 'header']);
  471. /*
  472. This handles our media attachments. Note that we don't show media on
  473. muted (notification) statuses. If the media type is unknown, then we
  474. simply ignore it.
  475. After we have generated our appropriate media element and stored it in
  476. `media`, we snatch the thumbnail to use as our `background` if media
  477. backgrounds for collapsed statuses are enabled.
  478. */
  479. attachments = status.get('media_attachments');
  480. if (attachments.size && !muted) {
  481. if (attachments.some((item) => item.get('type') === 'unknown')) {
  482. } else if (
  483. attachments.getIn([0, 'type']) === 'video'
  484. ) {
  485. media = ( // Media type is 'video'
  486. <StatusPlayer
  487. media={attachments.get(0)}
  488. sensitive={status.get('sensitive')}
  489. letterbox={settings.getIn(['media', 'letterbox'])}
  490. fullwidth={settings.getIn(['media', 'fullwidth'])}
  491. height={250}
  492. onOpenVideo={onOpenVideo}
  493. />
  494. );
  495. mediaIcon = 'video-camera';
  496. } else { // Media type is 'image' or 'gifv'
  497. media = (
  498. <StatusGallery
  499. media={attachments}
  500. sensitive={status.get('sensitive')}
  501. letterbox={settings.getIn(['media', 'letterbox'])}
  502. fullwidth={settings.getIn(['media', 'fullwidth'])}
  503. height={250}
  504. onOpenMedia={onOpenMedia}
  505. autoPlayGif={autoPlayGif}
  506. />
  507. );
  508. mediaIcon = 'picture-o';
  509. }
  510. if (
  511. !status.get('sensitive') &&
  512. !(status.get('spoiler_text').length > 0) &&
  513. settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
  514. ) background = attachments.getIn([0, 'preview_url']);
  515. }
  516. /*
  517. Finally, we can render our status. We just put the pieces together
  518. from above. We only render the action bar if the status isn't
  519. collapsed.
  520. */
  521. return (
  522. <article
  523. className={
  524. `status${
  525. muted ? ' muted' : ''
  526. } status-${status.get('visibility')}${
  527. isExpanded === false ? ' collapsed' : ''
  528. }${
  529. isExpanded === false && background ? ' has-background' : ''
  530. }`
  531. }
  532. style={{
  533. backgroundImage: (
  534. isExpanded === false && background ?
  535. `url(${background})` :
  536. 'none'
  537. ),
  538. }}
  539. ref={handleRef}
  540. >
  541. {prepend && account ? (
  542. <StatusPrepend
  543. type={prepend}
  544. account={account}
  545. parseClick={parseClick}
  546. notificationId={this.props.notificationId}
  547. onDeleteNotification={this.props.onDeleteNotification}
  548. />
  549. ) : null}
  550. <StatusHeader
  551. account={status.get('account')}
  552. friend={account}
  553. mediaIcon={mediaIcon}
  554. visibility={status.get('visibility')}
  555. collapsible={settings.getIn(['collapsed', 'enabled'])}
  556. collapsed={isExpanded === false}
  557. parseClick={parseClick}
  558. setExpansion={setExpansion}
  559. />
  560. <StatusContent
  561. status={status}
  562. media={media}
  563. mediaIcon={mediaIcon}
  564. expanded={isExpanded}
  565. setExpansion={setExpansion}
  566. onHeightUpdate={saveHeight}
  567. parseClick={parseClick}
  568. disabled={!router}
  569. />
  570. {isExpanded !== false ? (
  571. <StatusActionBar
  572. {...other}
  573. status={status}
  574. account={status.get('account')}
  575. />
  576. ) : null}
  577. </article>
  578. );
  579. }
  580. }