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.

833 lines
29 KiB

6 years ago
6 years ago
6 years ago
6 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, Audio } from '../features/ui/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/utils/content_warning';
  18. import PollContainer from 'flavours/glitch/containers/poll_container';
  19. import { displayMedia } from 'flavours/glitch/initial_state';
  20. import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
  21. // We use the component (and not the container) since we do not want
  22. // to use the progress bar to show download progress
  23. import Bundle from '../features/ui/components/bundle';
  24. export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => {
  25. const displayName = status.getIn(['account', 'display_name']);
  26. const values = [
  27. displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
  28. status.get('spoiler_text') && !expanded ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
  29. intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
  30. status.getIn(['account', 'acct']),
  31. ];
  32. if (rebloggedByText) {
  33. values.push(rebloggedByText);
  34. }
  35. return values.join(', ');
  36. };
  37. export const defaultMediaVisibility = (status, settings) => {
  38. if (!status) {
  39. return undefined;
  40. }
  41. if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  42. status = status.get('reblog');
  43. }
  44. if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) {
  45. return true;
  46. }
  47. return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
  48. };
  49. class Status extends ImmutablePureComponent {
  50. static contextTypes = {
  51. router: PropTypes.object,
  52. };
  53. static propTypes = {
  54. containerId: PropTypes.string,
  55. id: PropTypes.string,
  56. status: ImmutablePropTypes.map,
  57. account: ImmutablePropTypes.map,
  58. onReply: PropTypes.func,
  59. onFavourite: PropTypes.func,
  60. onReblog: PropTypes.func,
  61. onBookmark: PropTypes.func,
  62. onDelete: PropTypes.func,
  63. onDirect: PropTypes.func,
  64. onMention: PropTypes.func,
  65. onPin: PropTypes.func,
  66. onOpenMedia: PropTypes.func,
  67. onOpenVideo: PropTypes.func,
  68. onBlock: PropTypes.func,
  69. onAddFilter: PropTypes.func,
  70. onEmbed: PropTypes.func,
  71. onHeightChange: PropTypes.func,
  72. onToggleHidden: PropTypes.func,
  73. onTranslate: PropTypes.func,
  74. onInteractionModal: PropTypes.func,
  75. muted: PropTypes.bool,
  76. hidden: PropTypes.bool,
  77. unread: PropTypes.bool,
  78. prepend: PropTypes.string,
  79. withDismiss: PropTypes.bool,
  80. onMoveUp: PropTypes.func,
  81. onMoveDown: PropTypes.func,
  82. getScrollPosition: PropTypes.func,
  83. updateScrollBottom: PropTypes.func,
  84. expanded: PropTypes.bool,
  85. intl: PropTypes.object.isRequired,
  86. cacheMediaWidth: PropTypes.func,
  87. cachedMediaWidth: PropTypes.number,
  88. onClick: PropTypes.func,
  89. scrollKey: PropTypes.string,
  90. deployPictureInPicture: PropTypes.func,
  91. settings: ImmutablePropTypes.map.isRequired,
  92. pictureInPicture: ImmutablePropTypes.contains({
  93. inUse: PropTypes.bool,
  94. available: PropTypes.bool,
  95. }),
  96. };
  97. state = {
  98. isCollapsed: false,
  99. autoCollapsed: false,
  100. isExpanded: undefined,
  101. showMedia: undefined,
  102. statusId: undefined,
  103. revealBehindCW: undefined,
  104. showCard: false,
  105. forceFilter: undefined,
  106. };
  107. // Avoid checking props that are functions (and whose equality will always
  108. // evaluate to false. See react-immutable-pure-component for usage.
  109. updateOnProps = [
  110. 'status',
  111. 'account',
  112. 'settings',
  113. 'prepend',
  114. 'muted',
  115. 'notification',
  116. 'hidden',
  117. 'expanded',
  118. 'unread',
  119. 'pictureInPicture',
  120. ];
  121. updateOnStates = [
  122. 'isExpanded',
  123. 'isCollapsed',
  124. 'showMedia',
  125. 'forceFilter',
  126. ];
  127. // If our settings have changed to disable collapsed statuses, then we
  128. // need to make sure that we uncollapse every one. We do that by watching
  129. // for changes to `settings.collapsed.enabled` in
  130. // `getderivedStateFromProps()`.
  131. // We also need to watch for changes on the `collapse` prop---if this
  132. // changes to anything other than `undefined`, then we need to collapse or
  133. // uncollapse our status accordingly.
  134. static getDerivedStateFromProps(nextProps, prevState) {
  135. let update = {};
  136. let updated = false;
  137. // Make sure the state mirrors props we track…
  138. if (nextProps.expanded !== prevState.expandedProp) {
  139. update.expandedProp = nextProps.expanded;
  140. updated = true;
  141. }
  142. if (nextProps.status?.get('hidden') !== prevState.statusPropHidden) {
  143. update.statusPropHidden = nextProps.status?.get('hidden');
  144. updated = true;
  145. }
  146. // Update state based on new props
  147. if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
  148. if (prevState.isCollapsed) {
  149. update.isCollapsed = false;
  150. updated = true;
  151. }
  152. }
  153. // Handle uncollapsing toots when the shared CW state is expanded
  154. if (nextProps.settings.getIn(['content_warnings', 'shared_state']) &&
  155. nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false &&
  156. prevState.statusPropHidden !== false && prevState.isCollapsed
  157. ) {
  158. update.isCollapsed = false;
  159. updated = true;
  160. }
  161. // The “expanded” prop is used to one-off change the local state.
  162. // It's used in the thread view when unfolding/re-folding all CWs at once.
  163. if (nextProps.expanded !== prevState.expandedProp &&
  164. nextProps.expanded !== undefined
  165. ) {
  166. update.isExpanded = nextProps.expanded;
  167. if (nextProps.expanded) update.isCollapsed = false;
  168. updated = true;
  169. }
  170. if (prevState.isExpanded === undefined && update.isExpanded === undefined) {
  171. update.isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
  172. updated = true;
  173. }
  174. if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
  175. update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
  176. update.statusId = nextProps.status.get('id');
  177. updated = true;
  178. }
  179. if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
  180. update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
  181. if (update.revealBehindCW) {
  182. update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
  183. }
  184. updated = true;
  185. }
  186. return updated ? update : null;
  187. }
  188. // When mounting, we just check to see if our status should be collapsed,
  189. // and collapse it if so. We don't need to worry about whether collapsing
  190. // is enabled here, because `setCollapsed()` already takes that into
  191. // account.
  192. // The cases where a status should be collapsed are:
  193. //
  194. // - The `collapse` prop has been set to `true`
  195. // - The user has decided in local settings to collapse all statuses.
  196. // - The user has decided to collapse all notifications ('muted'
  197. // statuses).
  198. // - The user has decided to collapse long statuses and the status is
  199. // over the user set value (default 400 without media, or 610px with).
  200. // - The status is a reply and the user has decided to collapse all
  201. // replies.
  202. // - The status contains media and the user has decided to collapse all
  203. // statuses with media.
  204. // - The status is a reblog the user has decided to collapse all
  205. // statuses which are reblogs.
  206. componentDidMount () {
  207. const { node } = this;
  208. const {
  209. status,
  210. settings,
  211. collapse,
  212. muted,
  213. prepend,
  214. } = this.props;
  215. // Prevent a crash when node is undefined. Not completely sure why this
  216. // happens, might be because status === null.
  217. if (node === undefined) return;
  218. const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
  219. // Don't autocollapse if CW state is shared and status is explicitly revealed,
  220. // as it could cause surprising changes when receiving notifications
  221. if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
  222. let autoCollapseHeight = parseInt(autoCollapseSettings.get('height'));
  223. if (status.get('media_attachments').size && !muted) {
  224. autoCollapseHeight += 210;
  225. }
  226. if (collapse ||
  227. autoCollapseSettings.get('all') ||
  228. (autoCollapseSettings.get('notifications') && muted) ||
  229. (autoCollapseSettings.get('lengthy') && node.clientHeight > autoCollapseHeight) ||
  230. (autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
  231. (autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
  232. (autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
  233. ) {
  234. this.setCollapsed(true);
  235. // Hack to fix timeline jumps on second rendering when auto-collapsing
  236. this.setState({ autoCollapsed: true });
  237. }
  238. // Hack to fix timeline jumps when a preview card is fetched
  239. this.setState({
  240. showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
  241. });
  242. }
  243. // Hack to fix timeline jumps on second rendering when auto-collapsing
  244. // or on subsequent rendering when a preview card has been fetched
  245. getSnapshotBeforeUpdate (prevProps, prevState) {
  246. if (!this.props.getScrollPosition) return null;
  247. const { muted, hidden, status, settings } = this.props;
  248. const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
  249. if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
  250. if (doShowCard) this.setState({ showCard: true });
  251. if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
  252. return this.props.getScrollPosition();
  253. } else {
  254. return null;
  255. }
  256. }
  257. componentDidUpdate (prevProps, prevState, snapshot) {
  258. if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
  259. this.props.updateScrollBottom(snapshot.height - snapshot.top);
  260. }
  261. }
  262. componentWillUnmount() {
  263. if (this.node && this.props.getScrollPosition) {
  264. const position = this.props.getScrollPosition();
  265. if (position !== null && this.node.offsetTop < position.top) {
  266. requestAnimationFrame(() => {
  267. this.props.updateScrollBottom(position.height - position.top);
  268. });
  269. }
  270. }
  271. }
  272. // `setCollapsed()` sets the value of `isCollapsed` in our state, that is,
  273. // whether the toot is collapsed or not.
  274. // `setCollapsed()` automatically checks for us whether toot collapsing
  275. // is enabled, so we don't have to.
  276. setCollapsed = (value) => {
  277. if (this.props.settings.getIn(['collapsed', 'enabled'])) {
  278. if (value) {
  279. this.setExpansion(false);
  280. }
  281. this.setState({ isCollapsed: value });
  282. } else {
  283. this.setState({ isCollapsed: false });
  284. }
  285. };
  286. setExpansion = (value) => {
  287. if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) {
  288. this.props.onToggleHidden(this.props.status);
  289. }
  290. this.setState({ isExpanded: value });
  291. if (value) {
  292. this.setCollapsed(false);
  293. }
  294. };
  295. // `parseClick()` takes a click event and responds appropriately.
  296. // If our status is collapsed, then clicking on it should uncollapse it.
  297. // If `Shift` is held, then clicking on it should collapse it.
  298. // Otherwise, we open the url handed to us in `destination`, if
  299. // applicable.
  300. parseClick = (e, destination) => {
  301. const { router } = this.context;
  302. const { status } = this.props;
  303. const { isCollapsed } = this.state;
  304. if (!router) return;
  305. if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
  306. if (isCollapsed) this.setCollapsed(false);
  307. else if (e.shiftKey) {
  308. this.setCollapsed(true);
  309. document.getSelection().removeAllRanges();
  310. } else if (this.props.onClick) {
  311. this.props.onClick();
  312. return;
  313. } else {
  314. if (destination === undefined) {
  315. destination = `/@${
  316. status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
  317. }/${
  318. status.getIn(['reblog', 'id'], status.get('id'))
  319. }`;
  320. }
  321. let state = { ...router.history.location.state };
  322. state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
  323. router.history.push(destination, state);
  324. }
  325. e.preventDefault();
  326. }
  327. };
  328. handleToggleMediaVisibility = () => {
  329. this.setState({ showMedia: !this.state.showMedia });
  330. };
  331. handleExpandedToggle = () => {
  332. if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
  333. this.props.onToggleHidden(this.props.status);
  334. } else if (this.props.status.get('spoiler_text')) {
  335. this.setExpansion(!this.state.isExpanded);
  336. }
  337. };
  338. handleOpenVideo = (options) => {
  339. const { status } = this.props;
  340. this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
  341. };
  342. handleOpenMedia = (media, index) => {
  343. this.props.onOpenMedia(this.props.status.get('id'), media, index);
  344. };
  345. handleHotkeyOpenMedia = e => {
  346. const { status, onOpenMedia, onOpenVideo } = this.props;
  347. const statusId = status.get('id');
  348. e.preventDefault();
  349. if (status.get('media_attachments').size > 0) {
  350. if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
  351. onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 });
  352. } else {
  353. onOpenMedia(statusId, status.get('media_attachments'), 0);
  354. }
  355. }
  356. };
  357. handleDeployPictureInPicture = (type, mediaProps) => {
  358. const { deployPictureInPicture, status } = this.props;
  359. deployPictureInPicture(status, type, mediaProps);
  360. };
  361. handleHotkeyReply = e => {
  362. e.preventDefault();
  363. this.props.onReply(this.props.status, this.context.router.history);
  364. };
  365. handleHotkeyFavourite = (e) => {
  366. this.props.onFavourite(this.props.status, e);
  367. };
  368. handleHotkeyBoost = e => {
  369. this.props.onReblog(this.props.status, e);
  370. };
  371. handleHotkeyBookmark = e => {
  372. this.props.onBookmark(this.props.status, e);
  373. };
  374. handleHotkeyMention = e => {
  375. e.preventDefault();
  376. this.props.onMention(this.props.status.get('account'), this.context.router.history);
  377. };
  378. handleHotkeyOpen = () => {
  379. let state = { ...this.context.router.history.location.state };
  380. state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
  381. const status = this.props.status;
  382. this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
  383. };
  384. handleHotkeyOpenProfile = () => {
  385. let state = { ...this.context.router.history.location.state };
  386. state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
  387. this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
  388. };
  389. handleHotkeyMoveUp = e => {
  390. this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
  391. };
  392. handleHotkeyMoveDown = e => {
  393. this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
  394. };
  395. handleHotkeyCollapse = e => {
  396. if (!this.props.settings.getIn(['collapsed', 'enabled']))
  397. return;
  398. this.setCollapsed(!this.state.isCollapsed);
  399. };
  400. handleHotkeyToggleSensitive = () => {
  401. this.handleToggleMediaVisibility();
  402. };
  403. handleUnfilterClick = e => {
  404. this.setState({ forceFilter: false });
  405. e.preventDefault();
  406. };
  407. handleFilterClick = () => {
  408. this.setState({ forceFilter: true });
  409. };
  410. handleRef = c => {
  411. this.node = c;
  412. };
  413. handleTranslate = () => {
  414. this.props.onTranslate(this.props.status);
  415. };
  416. renderLoadingMediaGallery () {
  417. return <div className='media-gallery' style={{ height: '110px' }} />;
  418. }
  419. renderLoadingVideoPlayer () {
  420. return <div className='video-player' style={{ height: '110px' }} />;
  421. }
  422. renderLoadingAudioPlayer () {
  423. return <div className='audio-player' style={{ height: '110px' }} />;
  424. }
  425. render () {
  426. const {
  427. handleRef,
  428. parseClick,
  429. setExpansion,
  430. setCollapsed,
  431. } = this;
  432. const { router } = this.context;
  433. const {
  434. intl,
  435. status,
  436. account,
  437. settings,
  438. collapsed,
  439. muted,
  440. intersectionObserverWrapper,
  441. onOpenVideo,
  442. onOpenMedia,
  443. notification,
  444. hidden,
  445. unread,
  446. featured,
  447. pictureInPicture,
  448. ...other
  449. } = this.props;
  450. const { isCollapsed, forceFilter } = this.state;
  451. let background = null;
  452. let attachments = null;
  453. // Depending on user settings, some media are considered as parts of the
  454. // contents (affected by CW) while other will be displayed outside of the
  455. // CW.
  456. let contentMedia = [];
  457. let contentMediaIcons = [];
  458. let extraMedia = [];
  459. let extraMediaIcons = [];
  460. let media = contentMedia;
  461. let mediaIcons = contentMediaIcons;
  462. if (settings.getIn(['content_warnings', 'media_outside'])) {
  463. media = extraMedia;
  464. mediaIcons = extraMediaIcons;
  465. }
  466. if (status === null) {
  467. return null;
  468. }
  469. const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
  470. const handlers = {
  471. reply: this.handleHotkeyReply,
  472. favourite: this.handleHotkeyFavourite,
  473. boost: this.handleHotkeyBoost,
  474. mention: this.handleHotkeyMention,
  475. open: this.handleHotkeyOpen,
  476. openProfile: this.handleHotkeyOpenProfile,
  477. moveUp: this.handleHotkeyMoveUp,
  478. moveDown: this.handleHotkeyMoveDown,
  479. toggleSpoiler: this.handleExpandedToggle,
  480. bookmark: this.handleHotkeyBookmark,
  481. toggleCollapse: this.handleHotkeyCollapse,
  482. toggleSensitive: this.handleHotkeyToggleSensitive,
  483. openMedia: this.handleHotkeyOpenMedia,
  484. };
  485. if (hidden) {
  486. return (
  487. <HotKeys handlers={handlers}>
  488. <div ref={this.handleRef} className='status focusable' tabIndex='0'>
  489. <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
  490. <span>{status.get('content')}</span>
  491. </div>
  492. </HotKeys>
  493. );
  494. }
  495. const matchedFilters = status.get('matched_filters');
  496. if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
  497. const minHandlers = this.props.muted ? {} : {
  498. moveUp: this.handleHotkeyMoveUp,
  499. moveDown: this.handleHotkeyMoveDown,
  500. };
  501. return (
  502. <HotKeys handlers={minHandlers}>
  503. <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
  504. <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
  505. {' '}
  506. <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
  507. <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
  508. </button>
  509. </div>
  510. </HotKeys>
  511. );
  512. }
  513. // If user backgrounds for collapsed statuses are enabled, then we
  514. // initialize our background accordingly. This will only be rendered if
  515. // the status is collapsed.
  516. if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
  517. background = status.getIn(['account', 'header']);
  518. }
  519. // This handles our media attachments.
  520. // If a media file is of unknwon type or if the status is muted
  521. // (notification), we show a list of links instead of embedded media.
  522. // After we have generated our appropriate media element and stored it in
  523. // `media`, we snatch the thumbnail to use as our `background` if media
  524. // backgrounds for collapsed statuses are enabled.
  525. attachments = status.get('media_attachments');
  526. if (pictureInPicture.get('inUse')) {
  527. media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
  528. mediaIcons.push('video-camera');
  529. } else if (attachments.size > 0) {
  530. if (muted || attachments.some(item => item.get('type') === 'unknown')) {
  531. media.push(
  532. <AttachmentList
  533. compact
  534. media={status.get('media_attachments')}
  535. />,
  536. );
  537. } else if (attachments.getIn([0, 'type']) === 'audio') {
  538. const attachment = status.getIn(['media_attachments', 0]);
  539. media.push(
  540. <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
  541. {Component => (
  542. <Component
  543. src={attachment.get('url')}
  544. alt={attachment.get('description')}
  545. lang={status.get('language')}
  546. poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
  547. backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
  548. foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
  549. accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
  550. duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
  551. width={this.props.cachedMediaWidth}
  552. height={110}
  553. cacheWidth={this.props.cacheMediaWidth}
  554. deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
  555. sensitive={status.get('sensitive')}
  556. blurhash={attachment.get('blurhash')}
  557. visible={this.state.showMedia}
  558. onToggleVisibility={this.handleToggleMediaVisibility}
  559. />
  560. )}
  561. </Bundle>,
  562. );
  563. mediaIcons.push('music');
  564. } else if (attachments.getIn([0, 'type']) === 'video') {
  565. const attachment = status.getIn(['media_attachments', 0]);
  566. media.push(
  567. <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
  568. {Component => (<Component
  569. preview={attachment.get('preview_url')}
  570. frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
  571. blurhash={attachment.get('blurhash')}
  572. src={attachment.get('url')}
  573. alt={attachment.get('description')}
  574. lang={status.get('language')}
  575. inline
  576. sensitive={status.get('sensitive')}
  577. letterbox={settings.getIn(['media', 'letterbox'])}
  578. fullwidth={settings.getIn(['media', 'fullwidth'])}
  579. preventPlayback={isCollapsed || !isExpanded}
  580. onOpenVideo={this.handleOpenVideo}
  581. width={this.props.cachedMediaWidth}
  582. cacheWidth={this.props.cacheMediaWidth}
  583. deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
  584. visible={this.state.showMedia}
  585. onToggleVisibility={this.handleToggleMediaVisibility}
  586. />)}
  587. </Bundle>,
  588. );
  589. mediaIcons.push('video-camera');
  590. } else { // Media type is 'image' or 'gifv'
  591. media.push(
  592. <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
  593. {Component => (
  594. <Component
  595. media={attachments}
  596. lang={status.get('language')}
  597. sensitive={status.get('sensitive')}
  598. letterbox={settings.getIn(['media', 'letterbox'])}
  599. fullwidth={settings.getIn(['media', 'fullwidth'])}
  600. hidden={isCollapsed || !isExpanded}
  601. onOpenMedia={this.handleOpenMedia}
  602. cacheWidth={this.props.cacheMediaWidth}
  603. defaultWidth={this.props.cachedMediaWidth}
  604. visible={this.state.showMedia}
  605. onToggleVisibility={this.handleToggleMediaVisibility}
  606. />
  607. )}
  608. </Bundle>,
  609. );
  610. mediaIcons.push('picture-o');
  611. }
  612. if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
  613. background = attachments.getIn([0, 'preview_url']);
  614. }
  615. } else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
  616. media.push(
  617. <Card
  618. onOpenMedia={this.handleOpenMedia}
  619. card={status.get('card')}
  620. compact
  621. cacheWidth={this.props.cacheMediaWidth}
  622. defaultWidth={this.props.cachedMediaWidth}
  623. sensitive={status.get('sensitive')}
  624. />,
  625. );
  626. mediaIcons.push('link');
  627. }
  628. if (status.get('poll')) {
  629. contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
  630. contentMediaIcons.push('tasks');
  631. }
  632. // Here we prepare extra data-* attributes for CSS selectors.
  633. // Users can use those for theming, hiding avatars etc via UserStyle
  634. const selectorAttribs = {
  635. 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
  636. };
  637. let prepend;
  638. if (this.props.prepend && account) {
  639. const notifKind = {
  640. favourite: 'favourited',
  641. reblog: 'boosted',
  642. reblogged_by: 'boosted',
  643. status: 'posted',
  644. }[this.props.prepend];
  645. selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
  646. prepend = (
  647. <StatusPrepend
  648. type={this.props.prepend}
  649. account={account}
  650. parseClick={parseClick}
  651. notificationId={this.props.notificationId}
  652. />
  653. );
  654. }
  655. let rebloggedByText;
  656. if (this.props.prepend === 'reblog') {
  657. rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
  658. }
  659. const computedClass = classNames('status', `status-${status.get('visibility')}`, {
  660. collapsed: isCollapsed,
  661. 'has-background': isCollapsed && background,
  662. 'status__wrapper-reply': !!status.get('in_reply_to_id'),
  663. unread,
  664. muted,
  665. }, 'focusable');
  666. return (
  667. <HotKeys handlers={handlers}>
  668. <div
  669. className={computedClass}
  670. style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
  671. {...selectorAttribs}
  672. ref={handleRef}
  673. tabIndex='0'
  674. data-featured={featured ? 'true' : null}
  675. aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
  676. >
  677. {!muted && prepend}
  678. <header className='status__info'>
  679. <span>
  680. {muted && prepend}
  681. {!muted || !isCollapsed ? (
  682. <StatusHeader
  683. status={status}
  684. friend={account}
  685. collapsed={isCollapsed}
  686. parseClick={parseClick}
  687. />
  688. ) : null}
  689. </span>
  690. <StatusIcons
  691. status={status}
  692. mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
  693. collapsible={settings.getIn(['collapsed', 'enabled'])}
  694. collapsed={isCollapsed}
  695. setCollapsed={setCollapsed}
  696. settings={settings.get('status_icons')}
  697. />
  698. </header>
  699. <StatusContent
  700. status={status}
  701. media={contentMedia}
  702. extraMedia={extraMedia}
  703. mediaIcons={contentMediaIcons}
  704. expanded={isExpanded}
  705. onExpandedToggle={this.handleExpandedToggle}
  706. onTranslate={this.handleTranslate}
  707. parseClick={parseClick}
  708. disabled={!router}
  709. tagLinks={settings.get('tag_misleading_links')}
  710. rewriteMentions={settings.get('rewrite_mentions')}
  711. />
  712. {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
  713. <StatusActionBar
  714. status={status}
  715. account={status.get('account')}
  716. showReplyCount={settings.get('show_reply_count')}
  717. onFilter={matchedFilters ? this.handleFilterClick : null}
  718. {...other}
  719. />
  720. ) : null}
  721. {notification ? (
  722. <NotificationOverlayContainer
  723. notification={notification}
  724. />
  725. ) : null}
  726. </div>
  727. </HotKeys>
  728. );
  729. }
  730. }
  731. export default injectIntl(Status);