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.

520 lines
16 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. getScrollPosition: PropTypes.func,
  45. updateScrollBottom: PropTypes.func,
  46. expanded: PropTypes.bool,
  47. };
  48. state = {
  49. isCollapsed: false,
  50. autoCollapsed: false,
  51. }
  52. // Avoid checking props that are functions (and whose equality will always
  53. // evaluate to false. See react-immutable-pure-component for usage.
  54. updateOnProps = [
  55. 'status',
  56. 'account',
  57. 'settings',
  58. 'prepend',
  59. 'boostModal',
  60. 'favouriteModal',
  61. 'muted',
  62. 'collapse',
  63. 'notification',
  64. 'hidden',
  65. 'expanded',
  66. ]
  67. updateOnStates = [
  68. 'isExpanded',
  69. 'isCollapsed',
  70. ]
  71. // If our settings have changed to disable collapsed statuses, then we
  72. // need to make sure that we uncollapse every one. We do that by watching
  73. // for changes to `settings.collapsed.enabled` in
  74. // `getderivedStateFromProps()`.
  75. // We also need to watch for changes on the `collapse` prop---if this
  76. // changes to anything other than `undefined`, then we need to collapse or
  77. // uncollapse our status accordingly.
  78. static getDerivedStateFromProps(nextProps, prevState) {
  79. let update = {};
  80. let updated = false;
  81. // Make sure the state mirrors props we track…
  82. if (nextProps.collapse !== prevState.collapseProp) {
  83. update.collapseProp = nextProps.collapse;
  84. updated = true;
  85. }
  86. if (nextProps.expanded !== prevState.expandedProp) {
  87. update.expandedProp = nextProps.expanded;
  88. updated = true;
  89. }
  90. // Update state based on new props
  91. if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
  92. if (prevState.isCollapsed) {
  93. update.isCollapsed = false;
  94. updated = true;
  95. }
  96. } else if (
  97. nextProps.collapse !== prevState.collapseProp &&
  98. nextProps.collapse !== undefined
  99. ) {
  100. update.isCollapsed = nextProps.collapse;
  101. if (nextProps.collapse) update.isExpanded = false;
  102. updated = true;
  103. }
  104. if (nextProps.expanded !== prevState.expandedProp &&
  105. nextProps.expanded !== undefined
  106. ) {
  107. update.isExpanded = nextProps.expanded;
  108. if (nextProps.expanded) update.isCollapsed = false;
  109. updated = true;
  110. }
  111. return updated ? update : null;
  112. }
  113. // When mounting, we just check to see if our status should be collapsed,
  114. // and collapse it if so. We don't need to worry about whether collapsing
  115. // is enabled here, because `setCollapsed()` already takes that into
  116. // account.
  117. // The cases where a status should be collapsed are:
  118. //
  119. // - The `collapse` prop has been set to `true`
  120. // - The user has decided in local settings to collapse all statuses.
  121. // - The user has decided to collapse all notifications ('muted'
  122. // statuses).
  123. // - The user has decided to collapse long statuses and the status is
  124. // over 400px (without media, or 650px with).
  125. // - The status is a reply and the user has decided to collapse all
  126. // replies.
  127. // - The status contains media and the user has decided to collapse all
  128. // statuses with media.
  129. // - The status is a reblog the user has decided to collapse all
  130. // statuses which are reblogs.
  131. componentDidMount () {
  132. const { node } = this;
  133. const {
  134. status,
  135. settings,
  136. collapse,
  137. muted,
  138. prepend,
  139. } = this.props;
  140. const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
  141. if (function () {
  142. switch (true) {
  143. case !!collapse:
  144. case !!autoCollapseSettings.get('all'):
  145. case autoCollapseSettings.get('notifications') && !!muted:
  146. case autoCollapseSettings.get('lengthy') && node.clientHeight > (
  147. status.get('media_attachments').size && !muted ? 650 : 400
  148. ):
  149. case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
  150. case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
  151. case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
  152. return true;
  153. default:
  154. return false;
  155. }
  156. }()) {
  157. this.setCollapsed(true);
  158. // Hack to fix timeline jumps on second rendering when auto-collapsing
  159. this.setState({ autoCollapsed: true });
  160. }
  161. }
  162. getSnapshotBeforeUpdate (prevProps, prevState) {
  163. if (this.props.getScrollPosition) {
  164. return this.props.getScrollPosition();
  165. } else {
  166. return null;
  167. }
  168. }
  169. // Hack to fix timeline jumps on second rendering when auto-collapsing
  170. componentDidUpdate (prevProps, prevState, snapshot) {
  171. if (this.state.autoCollapsed) {
  172. this.setState({ autoCollapsed: false });
  173. if (snapshot !== null && this.props.updateScrollBottom) {
  174. if (this.node.offsetTop < snapshot.top) {
  175. this.props.updateScrollBottom(snapshot.height - snapshot.top);
  176. }
  177. }
  178. }
  179. }
  180. // `setCollapsed()` sets the value of `isCollapsed` in our state, that is,
  181. // whether the toot is collapsed or not.
  182. // `setCollapsed()` automatically checks for us whether toot collapsing
  183. // is enabled, so we don't have to.
  184. setCollapsed = (value) => {
  185. if (this.props.settings.getIn(['collapsed', 'enabled'])) {
  186. this.setState({ isCollapsed: value });
  187. if (value) {
  188. this.setExpansion(false);
  189. }
  190. } else {
  191. this.setState({ isCollapsed: false });
  192. }
  193. }
  194. setExpansion = (value) => {
  195. this.setState({ isExpanded: value });
  196. if (value) {
  197. this.setCollapsed(false);
  198. }
  199. }
  200. // `parseClick()` takes a click event and responds appropriately.
  201. // If our status is collapsed, then clicking on it should uncollapse it.
  202. // If `Shift` is held, then clicking on it should collapse it.
  203. // Otherwise, we open the url handed to us in `destination`, if
  204. // applicable.
  205. parseClick = (e, destination) => {
  206. const { router } = this.context;
  207. const { status } = this.props;
  208. const { isCollapsed } = this.state;
  209. if (!router) return;
  210. if (destination === undefined) {
  211. destination = `/statuses/${
  212. status.getIn(['reblog', 'id'], status.get('id'))
  213. }`;
  214. }
  215. if (e.button === 0) {
  216. if (isCollapsed) this.setCollapsed(false);
  217. else if (e.shiftKey) {
  218. this.setCollapsed(true);
  219. document.getSelection().removeAllRanges();
  220. } else router.history.push(destination);
  221. e.preventDefault();
  222. }
  223. }
  224. handleAccountClick = (e) => {
  225. if (this.context.router && e.button === 0) {
  226. const id = e.currentTarget.getAttribute('data-id');
  227. e.preventDefault();
  228. this.context.router.history.push(`/accounts/${id}`);
  229. }
  230. }
  231. handleExpandedToggle = () => {
  232. if (this.props.status.get('spoiler_text')) {
  233. this.setExpansion(!this.state.isExpanded);
  234. }
  235. };
  236. handleOpenVideo = startTime => {
  237. this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
  238. }
  239. handleHotkeyReply = e => {
  240. e.preventDefault();
  241. this.props.onReply(this.props.status, this.context.router.history);
  242. }
  243. handleHotkeyFavourite = (e) => {
  244. this.props.onFavourite(this.props.status, e);
  245. }
  246. handleHotkeyBoost = e => {
  247. this.props.onReblog(this.props.status, e);
  248. }
  249. handleHotkeyMention = e => {
  250. e.preventDefault();
  251. this.props.onMention(this.props.status.get('account'), this.context.router.history);
  252. }
  253. handleHotkeyOpen = () => {
  254. this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
  255. }
  256. handleHotkeyOpenProfile = () => {
  257. this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
  258. }
  259. handleHotkeyMoveUp = e => {
  260. this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
  261. }
  262. handleHotkeyMoveDown = e => {
  263. this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
  264. }
  265. handleRef = c => {
  266. this.node = c;
  267. }
  268. renderLoadingMediaGallery () {
  269. return <div className='media_gallery' style={{ height: '110px' }} />;
  270. }
  271. renderLoadingVideoPlayer () {
  272. return <div className='media-spoiler-video' style={{ height: '110px' }} />;
  273. }
  274. render () {
  275. const {
  276. handleRef,
  277. parseClick,
  278. setExpansion,
  279. setCollapsed,
  280. } = this;
  281. const { router } = this.context;
  282. const {
  283. status,
  284. account,
  285. settings,
  286. collapsed,
  287. muted,
  288. prepend,
  289. intersectionObserverWrapper,
  290. onOpenVideo,
  291. onOpenMedia,
  292. notification,
  293. hidden,
  294. featured,
  295. ...other
  296. } = this.props;
  297. const { isExpanded, isCollapsed } = this.state;
  298. let background = null;
  299. let attachments = null;
  300. let media = null;
  301. let mediaIcon = null;
  302. if (status === null) {
  303. return null;
  304. }
  305. if (hidden) {
  306. return (
  307. <div
  308. ref={this.handleRef}
  309. data-id={status.get('id')}
  310. style={{
  311. height: `${this.height}px`,
  312. opacity: 0,
  313. overflow: 'hidden',
  314. }}
  315. >
  316. {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
  317. {' '}
  318. {status.get('content')}
  319. </div>
  320. );
  321. }
  322. // If user backgrounds for collapsed statuses are enabled, then we
  323. // initialize our background accordingly. This will only be rendered if
  324. // the status is collapsed.
  325. if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
  326. background = status.getIn(['account', 'header']);
  327. }
  328. // This handles our media attachments.
  329. // If a media file is of unknwon type or if the status is muted
  330. // (notification), we show a list of links instead of embedded media.
  331. // After we have generated our appropriate media element and stored it in
  332. // `media`, we snatch the thumbnail to use as our `background` if media
  333. // backgrounds for collapsed statuses are enabled.
  334. attachments = status.get('media_attachments');
  335. if (attachments.size > 0) {
  336. if (muted || attachments.some(item => item.get('type') === 'unknown')) {
  337. media = (
  338. <AttachmentList
  339. compact
  340. media={status.get('media_attachments')}
  341. />
  342. );
  343. } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
  344. const video = status.getIn(['media_attachments', 0]);
  345. media = (
  346. <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
  347. {Component => (<Component
  348. preview={video.get('preview_url')}
  349. src={video.get('url')}
  350. inline
  351. sensitive={status.get('sensitive')}
  352. letterbox={settings.getIn(['media', 'letterbox'])}
  353. fullwidth={settings.getIn(['media', 'fullwidth'])}
  354. onOpenVideo={this.handleOpenVideo}
  355. />)}
  356. </Bundle>
  357. );
  358. mediaIcon = 'video-camera';
  359. } else { // Media type is 'image' or 'gifv'
  360. media = (
  361. <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
  362. {Component => (
  363. <Component
  364. media={attachments}
  365. sensitive={status.get('sensitive')}
  366. letterbox={settings.getIn(['media', 'letterbox'])}
  367. fullwidth={settings.getIn(['media', 'fullwidth'])}
  368. onOpenMedia={this.props.onOpenMedia}
  369. />
  370. )}
  371. </Bundle>
  372. );
  373. mediaIcon = 'picture-o';
  374. }
  375. if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
  376. background = attachments.getIn([0, 'preview_url']);
  377. }
  378. }
  379. // Here we prepare extra data-* attributes for CSS selectors.
  380. // Users can use those for theming, hiding avatars etc via UserStyle
  381. const selectorAttribs = {
  382. 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
  383. };
  384. if (prepend && account) {
  385. const notifKind = {
  386. favourite: 'favourited',
  387. reblog: 'boosted',
  388. reblogged_by: 'boosted',
  389. }[prepend];
  390. selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
  391. }
  392. const handlers = {
  393. reply: this.handleHotkeyReply,
  394. favourite: this.handleHotkeyFavourite,
  395. boost: this.handleHotkeyBoost,
  396. mention: this.handleHotkeyMention,
  397. open: this.handleHotkeyOpen,
  398. openProfile: this.handleHotkeyOpenProfile,
  399. moveUp: this.handleHotkeyMoveUp,
  400. moveDown: this.handleHotkeyMoveDown,
  401. toggleSpoiler: this.handleExpandedToggle,
  402. };
  403. const computedClass = classNames('status', `status-${status.get('visibility')}`, {
  404. collapsed: isCollapsed,
  405. 'has-background': isCollapsed && background,
  406. muted,
  407. }, 'focusable');
  408. return (
  409. <HotKeys handlers={handlers}>
  410. <div
  411. className={computedClass}
  412. style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
  413. {...selectorAttribs}
  414. ref={handleRef}
  415. tabIndex='0'
  416. data-featured={featured ? 'true' : null}
  417. >
  418. <header className='status__info'>
  419. <span>
  420. {prepend && account ? (
  421. <StatusPrepend
  422. type={prepend}
  423. account={account}
  424. parseClick={parseClick}
  425. notificationId={this.props.notificationId}
  426. />
  427. ) : null}
  428. {!muted || !isCollapsed ? (
  429. <StatusHeader
  430. status={status}
  431. friend={account}
  432. collapsed={isCollapsed}
  433. parseClick={parseClick}
  434. />
  435. ) : null}
  436. </span>
  437. <StatusIcons
  438. status={status}
  439. mediaIcon={mediaIcon}
  440. collapsible={settings.getIn(['collapsed', 'enabled'])}
  441. collapsed={isCollapsed}
  442. setCollapsed={setCollapsed}
  443. />
  444. </header>
  445. <StatusContent
  446. status={status}
  447. media={media}
  448. mediaIcon={mediaIcon}
  449. expanded={isExpanded}
  450. onExpandedToggle={this.handleExpandedToggle}
  451. parseClick={parseClick}
  452. disabled={!router}
  453. />
  454. {!isCollapsed || !muted ? (
  455. <StatusActionBar
  456. {...other}
  457. status={status}
  458. account={status.get('account')}
  459. />
  460. ) : null}
  461. {notification ? (
  462. <NotificationOverlayContainer
  463. notification={notification}
  464. />
  465. ) : null}
  466. </div>
  467. </HotKeys>
  468. );
  469. }
  470. }