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.

455 lines
14 KiB

7 years ago
7 years ago
7 years ago
  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import StatusPrepend from './status_prepend';
  5. import StatusHeader from './status_header';
  6. import StatusIcons from './status_icons';
  7. import StatusContent from './status_content';
  8. import StatusActionBar from './status_action_bar';
  9. import AttachmentList from './attachment_list';
  10. import ImmutablePureComponent from 'react-immutable-pure-component';
  11. import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
  12. import { HotKeys } from 'react-hotkeys';
  13. import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
  14. import classNames from 'classnames';
  15. // We use the component (and not the container) since we do not want
  16. // to use the progress bar to show download progress
  17. import Bundle from '../features/ui/components/bundle';
  18. export default class Status extends ImmutablePureComponent {
  19. static contextTypes = {
  20. router: PropTypes.object,
  21. };
  22. static propTypes = {
  23. containerId: PropTypes.string,
  24. id: PropTypes.string,
  25. status: ImmutablePropTypes.map,
  26. account: ImmutablePropTypes.map,
  27. onReply: PropTypes.func,
  28. onFavourite: PropTypes.func,
  29. onReblog: PropTypes.func,
  30. onDelete: PropTypes.func,
  31. onPin: PropTypes.func,
  32. onOpenMedia: PropTypes.func,
  33. onOpenVideo: PropTypes.func,
  34. onBlock: PropTypes.func,
  35. onEmbed: PropTypes.func,
  36. onHeightChange: PropTypes.func,
  37. muted: PropTypes.bool,
  38. collapse: PropTypes.bool,
  39. hidden: PropTypes.bool,
  40. prepend: PropTypes.string,
  41. withDismiss: PropTypes.bool,
  42. onMoveUp: PropTypes.func,
  43. onMoveDown: PropTypes.func,
  44. };
  45. state = {
  46. isExpanded: null,
  47. }
  48. // Avoid checking props that are functions (and whose equality will always
  49. // evaluate to false. See react-immutable-pure-component for usage.
  50. updateOnProps = [
  51. 'status',
  52. 'account',
  53. 'settings',
  54. 'prepend',
  55. 'boostModal',
  56. 'favouriteModal',
  57. 'muted',
  58. 'collapse',
  59. 'notification',
  60. 'hidden',
  61. ]
  62. updateOnStates = [
  63. 'isExpanded',
  64. ]
  65. // If our settings have changed to disable collapsed statuses, then we
  66. // need to make sure that we uncollapse every one. We do that by watching
  67. // for changes to `settings.collapsed.enabled` in
  68. // `componentWillReceiveProps()`.
  69. // We also need to watch for changes on the `collapse` prop---if this
  70. // changes to anything other than `undefined`, then we need to collapse or
  71. // uncollapse our status accordingly.
  72. componentWillReceiveProps (nextProps) {
  73. if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
  74. if (this.state.isExpanded === false) {
  75. this.setExpansion(null);
  76. }
  77. } else if (
  78. nextProps.collapse !== this.props.collapse &&
  79. nextProps.collapse !== undefined
  80. ) this.setExpansion(nextProps.collapse ? false : null);
  81. }
  82. // When mounting, we just check to see if our status should be collapsed,
  83. // and collapse it if so. We don't need to worry about whether collapsing
  84. // is enabled here, because `setExpansion()` already takes that into
  85. // account.
  86. // The cases where a status should be collapsed are:
  87. //
  88. // - The `collapse` prop has been set to `true`
  89. // - The user has decided in local settings to collapse all statuses.
  90. // - The user has decided to collapse all notifications ('muted'
  91. // statuses).
  92. // - The user has decided to collapse long statuses and the status is
  93. // over 400px (without media, or 650px with).
  94. // - The status is a reply and the user has decided to collapse all
  95. // replies.
  96. // - The status contains media and the user has decided to collapse all
  97. // statuses with media.
  98. // - The status is a reblog the user has decided to collapse all
  99. // statuses which are reblogs.
  100. componentDidMount () {
  101. const { node } = this;
  102. const {
  103. status,
  104. settings,
  105. collapse,
  106. muted,
  107. prepend,
  108. } = this.props;
  109. const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
  110. if (function () {
  111. switch (true) {
  112. case !!collapse:
  113. case !!autoCollapseSettings.get('all'):
  114. case autoCollapseSettings.get('notifications') && !!muted:
  115. case autoCollapseSettings.get('lengthy') && node.clientHeight > (
  116. status.get('media_attachments').size && !muted ? 650 : 400
  117. ):
  118. case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
  119. case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
  120. case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
  121. return true;
  122. default:
  123. return false;
  124. }
  125. }()) this.setExpansion(false);
  126. }
  127. // `setExpansion()` sets the value of `isExpanded` in our state. It takes
  128. // one argument, `value`, which gives the desired value for `isExpanded`.
  129. // The default for this argument is `null`.
  130. // `setExpansion()` automatically checks for us whether toot collapsing
  131. // is enabled, so we don't have to.
  132. setExpansion = (value) => {
  133. switch (true) {
  134. case value === undefined || value === null:
  135. this.setState({ isExpanded: null });
  136. break;
  137. case !value && this.props.settings.getIn(['collapsed', 'enabled']):
  138. this.setState({ isExpanded: false });
  139. break;
  140. case !!value:
  141. this.setState({ isExpanded: true });
  142. break;
  143. }
  144. }
  145. // `parseClick()` takes a click event and responds appropriately.
  146. // If our status is collapsed, then clicking on it should uncollapse it.
  147. // If `Shift` is held, then clicking on it should collapse it.
  148. // Otherwise, we open the url handed to us in `destination`, if
  149. // applicable.
  150. parseClick = (e, destination) => {
  151. const { router } = this.context;
  152. const { status } = this.props;
  153. const { isExpanded } = this.state;
  154. if (!router) return;
  155. if (destination === undefined) {
  156. destination = `/statuses/${
  157. status.getIn(['reblog', 'id'], status.get('id'))
  158. }`;
  159. }
  160. if (e.button === 0) {
  161. if (isExpanded === false) this.setExpansion(null);
  162. else if (e.shiftKey) {
  163. this.setExpansion(false);
  164. document.getSelection().removeAllRanges();
  165. } else router.history.push(destination);
  166. e.preventDefault();
  167. }
  168. }
  169. handleAccountClick = (e) => {
  170. if (this.context.router && e.button === 0) {
  171. const id = e.currentTarget.getAttribute('data-id');
  172. e.preventDefault();
  173. this.context.router.history.push(`/accounts/${id}`);
  174. }
  175. }
  176. handleExpandedToggle = () => {
  177. if (this.props.status.get('spoiler_text')) {
  178. this.setExpansion(this.state.isExpanded ? null : true);
  179. }
  180. };
  181. handleOpenVideo = startTime => {
  182. this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
  183. }
  184. handleHotkeyReply = e => {
  185. e.preventDefault();
  186. this.props.onReply(this.props.status, this.context.router.history);
  187. }
  188. handleHotkeyFavourite = (e) => {
  189. this.props.onFavourite(this.props.status, e);
  190. }
  191. handleHotkeyBoost = e => {
  192. this.props.onReblog(this.props.status, e);
  193. }
  194. handleHotkeyMention = e => {
  195. e.preventDefault();
  196. this.props.onMention(this.props.status.get('account'), this.context.router.history);
  197. }
  198. handleHotkeyOpen = () => {
  199. this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
  200. }
  201. handleHotkeyOpenProfile = () => {
  202. this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
  203. }
  204. handleHotkeyMoveUp = () => {
  205. this.props.onMoveUp(this.props.containerId || this.props.id);
  206. }
  207. handleHotkeyMoveDown = () => {
  208. this.props.onMoveDown(this.props.containerId || this.props.id);
  209. }
  210. handleRef = c => {
  211. this.node = c;
  212. }
  213. renderLoadingMediaGallery () {
  214. return <div className='media_gallery' style={{ height: '110px' }} />;
  215. }
  216. renderLoadingVideoPlayer () {
  217. return <div className='media-spoiler-video' style={{ height: '110px' }} />;
  218. }
  219. render () {
  220. const {
  221. handleRef,
  222. parseClick,
  223. setExpansion,
  224. } = this;
  225. const { router } = this.context;
  226. const {
  227. status,
  228. account,
  229. settings,
  230. collapsed,
  231. muted,
  232. prepend,
  233. intersectionObserverWrapper,
  234. onOpenVideo,
  235. onOpenMedia,
  236. notification,
  237. hidden,
  238. ...other
  239. } = this.props;
  240. const { isExpanded } = this.state;
  241. let background = null;
  242. let attachments = null;
  243. let media = null;
  244. let mediaIcon = null;
  245. if (status === null) {
  246. return null;
  247. }
  248. if (hidden) {
  249. return (
  250. <div
  251. ref={this.handleRef}
  252. data-id={status.get('id')}
  253. style={{
  254. height: `${this.height}px`,
  255. opacity: 0,
  256. overflow: 'hidden',
  257. }}
  258. >
  259. {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
  260. {' '}
  261. {status.get('content')}
  262. </div>
  263. );
  264. }
  265. // If user backgrounds for collapsed statuses are enabled, then we
  266. // initialize our background accordingly. This will only be rendered if
  267. // the status is collapsed.
  268. if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
  269. background = status.getIn(['account', 'header']);
  270. }
  271. // This handles our media attachments.
  272. // If a media file is of unknwon type or if the status is muted
  273. // (notification), we show a list of links instead of embedded media.
  274. // After we have generated our appropriate media element and stored it in
  275. // `media`, we snatch the thumbnail to use as our `background` if media
  276. // backgrounds for collapsed statuses are enabled.
  277. attachments = status.get('media_attachments');
  278. if (attachments.size > 0) {
  279. if (muted || attachments.some(item => item.get('type') === 'unknown')) {
  280. media = (
  281. <AttachmentList
  282. compact
  283. media={status.get('media_attachments')}
  284. />
  285. );
  286. } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
  287. const video = status.getIn(['media_attachments', 0]);
  288. media = (
  289. <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
  290. {Component => (<Component
  291. preview={video.get('preview_url')}
  292. src={video.get('url')}
  293. sensitive={status.get('sensitive')}
  294. letterbox={settings.getIn(['media', 'letterbox'])}
  295. fullwidth={settings.getIn(['media', 'fullwidth'])}
  296. onOpenVideo={this.handleOpenVideo}
  297. />)}
  298. </Bundle>
  299. );
  300. mediaIcon = 'video-camera';
  301. } else { // Media type is 'image' or 'gifv'
  302. media = (
  303. <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
  304. {Component => (
  305. <Component
  306. media={attachments}
  307. sensitive={status.get('sensitive')}
  308. letterbox={settings.getIn(['media', 'letterbox'])}
  309. fullwidth={settings.getIn(['media', 'fullwidth'])}
  310. onOpenMedia={this.props.onOpenMedia}
  311. />
  312. )}
  313. </Bundle>
  314. );
  315. mediaIcon = 'picture-o';
  316. }
  317. if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
  318. background = attachments.getIn([0, 'preview_url']);
  319. }
  320. }
  321. // Here we prepare extra data-* attributes for CSS selectors.
  322. // Users can use those for theming, hiding avatars etc via UserStyle
  323. const selectorAttribs = {
  324. 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
  325. };
  326. if (prepend && account) {
  327. const notifKind = {
  328. favourite: 'favourited',
  329. reblog: 'boosted',
  330. reblogged_by: 'boosted',
  331. }[prepend];
  332. selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
  333. }
  334. const handlers = {
  335. reply: this.handleHotkeyReply,
  336. favourite: this.handleHotkeyFavourite,
  337. boost: this.handleHotkeyBoost,
  338. mention: this.handleHotkeyMention,
  339. open: this.handleHotkeyOpen,
  340. openProfile: this.handleHotkeyOpenProfile,
  341. moveUp: this.handleHotkeyMoveUp,
  342. moveDown: this.handleHotkeyMoveDown,
  343. toggleSpoiler: this.handleExpandedToggle,
  344. };
  345. const computedClass = classNames('status', `status-${status.get('visibility')}`, {
  346. collapsed: isExpanded === false,
  347. 'has-background': isExpanded === false && background,
  348. muted,
  349. }, 'focusable');
  350. return (
  351. <HotKeys handlers={handlers}>
  352. <div
  353. className={computedClass}
  354. style={isExpanded === false && background ? { backgroundImage: `url(${background})` } : null}
  355. {...selectorAttribs}
  356. ref={handleRef}
  357. tabIndex='0'
  358. >
  359. <header className='status__info'>
  360. <span>
  361. {prepend && account ? (
  362. <StatusPrepend
  363. type={prepend}
  364. account={account}
  365. parseClick={parseClick}
  366. notificationId={this.props.notificationId}
  367. />
  368. ) : null}
  369. {!muted || isExpanded !== false ? (
  370. <StatusHeader
  371. status={status}
  372. friend={account}
  373. collapsed={isExpanded === false}
  374. parseClick={parseClick}
  375. />
  376. ) : null}
  377. </span>
  378. <StatusIcons
  379. status={status}
  380. mediaIcon={mediaIcon}
  381. collapsible={settings.getIn(['collapsed', 'enabled'])}
  382. collapsed={isExpanded === false}
  383. setExpansion={setExpansion}
  384. />
  385. </header>
  386. <StatusContent
  387. status={status}
  388. media={media}
  389. mediaIcon={mediaIcon}
  390. expanded={isExpanded}
  391. setExpansion={setExpansion}
  392. parseClick={parseClick}
  393. disabled={!router}
  394. />
  395. {isExpanded !== false || !muted ? (
  396. <StatusActionBar
  397. {...other}
  398. status={status}
  399. account={status.get('account')}
  400. />
  401. ) : null}
  402. {notification ? (
  403. <NotificationOverlayContainer
  404. notification={notification}
  405. />
  406. ) : null}
  407. </div>
  408. </HotKeys>
  409. );
  410. }
  411. }