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.

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