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.

473 lines
16 KiB

  1. import React from 'react';
  2. import Immutable from 'immutable';
  3. import ImmutablePropTypes from 'react-immutable-proptypes';
  4. import PropTypes from 'prop-types';
  5. import Avatar from './avatar';
  6. import AvatarOverlay from './avatar_overlay';
  7. import AvatarComposite from './avatar_composite';
  8. import RelativeTimestamp from './relative_timestamp';
  9. import DisplayName from './display_name';
  10. import StatusContent from './status_content';
  11. import StatusActionBar from './status_action_bar';
  12. import AttachmentList from './attachment_list';
  13. import Card from '../features/status/components/card';
  14. import { injectIntl, FormattedMessage } from 'react-intl';
  15. import ImmutablePureComponent from 'react-immutable-pure-component';
  16. import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
  17. import { HotKeys } from 'react-hotkeys';
  18. import classNames from 'classnames';
  19. import Icon from 'mastodon/components/icon';
  20. import { displayMedia } from '../initial_state';
  21. //import DetailedStatus from '../features/status/components/detailed_status';
  22. // We use the component (and not the container) since we do not want
  23. // to use the progress bar to show download progress
  24. import Bundle from '../features/ui/components/bundle';
  25. export const textForScreenReader = (intl, status, rebloggedByText = false) => {
  26. const displayName = status.getIn(['account', 'display_name']);
  27. const values = [
  28. displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
  29. status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
  30. intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
  31. status.getIn(['account', 'acct']),
  32. ];
  33. if (rebloggedByText) {
  34. values.push(rebloggedByText);
  35. }
  36. return values.join(', ');
  37. };
  38. export const defaultMediaVisibility = (status) => {
  39. if (!status) {
  40. return undefined;
  41. }
  42. if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  43. status = status.get('reblog');
  44. }
  45. return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
  46. };
  47. export default @injectIntl
  48. class Status extends ImmutablePureComponent {
  49. static contextTypes = {
  50. router: PropTypes.object,
  51. };
  52. static propTypes = {
  53. status: ImmutablePropTypes.map,
  54. account: ImmutablePropTypes.map,
  55. otherAccounts: ImmutablePropTypes.list,
  56. onClick: PropTypes.func,
  57. onReply: PropTypes.func,
  58. onFavourite: PropTypes.func,
  59. onReblog: PropTypes.func,
  60. onDelete: PropTypes.func,
  61. onDirect: PropTypes.func,
  62. onMention: PropTypes.func,
  63. onPin: PropTypes.func,
  64. onOpenMedia: PropTypes.func,
  65. onOpenVideo: PropTypes.func,
  66. onBlock: PropTypes.func,
  67. onEmbed: PropTypes.func,
  68. onHeightChange: PropTypes.func,
  69. onToggleHidden: PropTypes.func,
  70. muted: PropTypes.bool,
  71. hidden: PropTypes.bool,
  72. unread: PropTypes.bool,
  73. onMoveUp: PropTypes.func,
  74. onMoveDown: PropTypes.func,
  75. showThread: PropTypes.bool,
  76. getScrollPosition: PropTypes.func,
  77. updateScrollBottom: PropTypes.func,
  78. cacheMediaWidth: PropTypes.func,
  79. cachedMediaWidth: PropTypes.number,
  80. sonsIds: ImmutablePropTypes.list,
  81. };
  82. // Avoid checking props that are functions (and whose equality will always
  83. // evaluate to false. See react-immutable-pure-component for usage.
  84. updateOnProps = [
  85. 'status',
  86. 'account',
  87. 'muted',
  88. 'hidden',
  89. ];
  90. state = {
  91. showMedia: defaultMediaVisibility(this.props.status),
  92. statusId: undefined,
  93. };
  94. // Track height changes we know about to compensate scrolling
  95. componentDidMount () {
  96. this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
  97. }
  98. getSnapshotBeforeUpdate () {
  99. if (this.props.getScrollPosition) {
  100. return this.props.getScrollPosition();
  101. } else {
  102. return null;
  103. }
  104. }
  105. static getDerivedStateFromProps(nextProps, prevState) {
  106. if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
  107. return {
  108. showMedia: defaultMediaVisibility(nextProps.status),
  109. statusId: nextProps.status.get('id'),
  110. };
  111. } else {
  112. return null;
  113. }
  114. }
  115. // Compensate height changes
  116. componentDidUpdate (prevProps, prevState, snapshot) {
  117. const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
  118. if (doShowCard && !this.didShowCard) {
  119. this.didShowCard = true;
  120. if (snapshot !== null && this.props.updateScrollBottom) {
  121. if (this.node && this.node.offsetTop < snapshot.top) {
  122. this.props.updateScrollBottom(snapshot.height - snapshot.top);
  123. }
  124. }
  125. }
  126. }
  127. componentWillUnmount() {
  128. if (this.node && this.props.getScrollPosition) {
  129. const position = this.props.getScrollPosition();
  130. if (position !== null && this.node.offsetTop < position.top) {
  131. requestAnimationFrame(() => {
  132. this.props.updateScrollBottom(position.height - position.top);
  133. });
  134. }
  135. }
  136. }
  137. handleToggleMediaVisibility = () => {
  138. this.setState({ showMedia: !this.state.showMedia });
  139. }
  140. handleClick = () => {
  141. if (this.props.onClick) {
  142. this.props.onClick();
  143. return;
  144. }
  145. if (!this.context.router) {
  146. return;
  147. }
  148. const { status } = this.props;
  149. this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
  150. }
  151. handleExpandClick = (e) => {
  152. if (this.props.onClick) {
  153. this.props.onClick();
  154. return;
  155. }
  156. if (e.button === 0) {
  157. if (!this.context.router) {
  158. return;
  159. }
  160. const { status } = this.props;
  161. this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
  162. }
  163. }
  164. handleAccountClick = (e) => {
  165. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  166. const id = e.currentTarget.getAttribute('data-id');
  167. e.preventDefault();
  168. this.context.router.history.push(`/accounts/${id}`);
  169. }
  170. }
  171. handleExpandedToggle = () => {
  172. this.props.onToggleHidden(this._properStatus());
  173. };
  174. renderLoadingMediaGallery () {
  175. return <div className='media-gallery' style={{ height: '110px' }} />;
  176. }
  177. renderLoadingVideoPlayer () {
  178. return <div className='video-player' style={{ height: '110px' }} />;
  179. }
  180. renderLoadingAudioPlayer () {
  181. return <div className='audio-player' style={{ height: '110px' }} />;
  182. }
  183. handleOpenVideo = (media, startTime) => {
  184. this.props.onOpenVideo(media, startTime);
  185. }
  186. handleHotkeyReply = e => {
  187. e.preventDefault();
  188. this.props.onReply(this._properStatus(), this.context.router.history);
  189. }
  190. handleHotkeyFavourite = () => {
  191. this.props.onFavourite(this._properStatus());
  192. }
  193. handleHotkeyBoost = e => {
  194. this.props.onReblog(this._properStatus(), e);
  195. }
  196. handleHotkeyMention = e => {
  197. e.preventDefault();
  198. this.props.onMention(this._properStatus().get('account'), this.context.router.history);
  199. }
  200. handleHotkeyOpen = () => {
  201. this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
  202. }
  203. handleHotkeyOpenProfile = () => {
  204. this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
  205. }
  206. handleHotkeyMoveUp = e => {
  207. this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  208. }
  209. handleHotkeyMoveDown = e => {
  210. this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  211. }
  212. handleHotkeyToggleHidden = () => {
  213. this.props.onToggleHidden(this._properStatus());
  214. }
  215. handleHotkeyToggleSensitive = () => {
  216. this.handleToggleMediaVisibility();
  217. }
  218. _properStatus () {
  219. const { status } = this.props;
  220. if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  221. return status.get('reblog');
  222. } else {
  223. return status;
  224. }
  225. }
  226. handleRef = c => {
  227. this.node = c;
  228. }
  229. render () {
  230. let media = null;
  231. let statusAvatar, prepend, rebloggedByText;
  232. const { intl, hidden, featured, otherAccounts, unread, showThread, sonsIds } = this.props;
  233. let { status, account, ...other } = this.props;
  234. if (status === null) {
  235. return null;
  236. }
  237. const handlers = this.props.muted ? {} : {
  238. reply: this.handleHotkeyReply,
  239. favourite: this.handleHotkeyFavourite,
  240. boost: this.handleHotkeyBoost,
  241. mention: this.handleHotkeyMention,
  242. open: this.handleHotkeyOpen,
  243. openProfile: this.handleHotkeyOpenProfile,
  244. moveUp: this.handleHotkeyMoveUp,
  245. moveDown: this.handleHotkeyMoveDown,
  246. toggleHidden: this.handleHotkeyToggleHidden,
  247. toggleSensitive: this.handleHotkeyToggleSensitive,
  248. };
  249. if (hidden) {
  250. return (
  251. <HotKeys handlers={handlers}>
  252. <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
  253. {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
  254. {status.get('content')}
  255. </div>
  256. </HotKeys>
  257. );
  258. }
  259. if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
  260. const minHandlers = this.props.muted ? {} : {
  261. moveUp: this.handleHotkeyMoveUp,
  262. moveDown: this.handleHotkeyMoveDown,
  263. };
  264. return (
  265. <HotKeys handlers={minHandlers}>
  266. <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
  267. <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
  268. </div>
  269. </HotKeys>
  270. );
  271. }
  272. if (featured) {
  273. prepend = (
  274. <div className='status__prepend'>
  275. <div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
  276. <FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
  277. </div>
  278. );
  279. } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  280. const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
  281. prepend = (
  282. <div className='status__prepend'>
  283. <div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
  284. <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
  285. </div>
  286. );
  287. rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
  288. account = status.get('account');
  289. status = status.get('reblog');
  290. }
  291. if (status.get('media_attachments').size > 0) {
  292. if (this.props.muted) {
  293. media = (
  294. <AttachmentList
  295. compact
  296. media={status.get('media_attachments')}
  297. />
  298. );
  299. } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
  300. const attachment = status.getIn(['media_attachments', 0]);
  301. media = (
  302. <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
  303. {Component => (
  304. <Component
  305. src={attachment.get('url')}
  306. alt={attachment.get('description')}
  307. duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
  308. peaks={[0]}
  309. height={70}
  310. />
  311. )}
  312. </Bundle>
  313. );
  314. } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
  315. const attachment = status.getIn(['media_attachments', 0]);
  316. media = (
  317. <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
  318. {Component => (
  319. <Component
  320. preview={attachment.get('preview_url')}
  321. blurhash={attachment.get('blurhash')}
  322. src={attachment.get('url')}
  323. alt={attachment.get('description')}
  324. width={this.props.cachedMediaWidth}
  325. height={110}
  326. inline
  327. sensitive={status.get('sensitive')}
  328. onOpenVideo={this.handleOpenVideo}
  329. cacheWidth={this.props.cacheMediaWidth}
  330. visible={this.state.showMedia}
  331. onToggleVisibility={this.handleToggleMediaVisibility}
  332. />
  333. )}
  334. </Bundle>
  335. );
  336. } else {
  337. media = (
  338. <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
  339. {Component => (
  340. <Component
  341. media={status.get('media_attachments')}
  342. sensitive={status.get('sensitive')}
  343. height={110}
  344. onOpenMedia={this.props.onOpenMedia}
  345. cacheWidth={this.props.cacheMediaWidth}
  346. defaultWidth={this.props.cachedMediaWidth}
  347. visible={this.state.showMedia}
  348. onToggleVisibility={this.handleToggleMediaVisibility}
  349. />
  350. )}
  351. </Bundle>
  352. );
  353. }
  354. } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
  355. media = (
  356. <Card
  357. onOpenMedia={this.props.onOpenMedia}
  358. card={status.get('card')}
  359. compact
  360. cacheWidth={this.props.cacheMediaWidth}
  361. defaultWidth={this.props.cachedMediaWidth}
  362. />
  363. );
  364. }
  365. if (otherAccounts && otherAccounts.size > 0) {
  366. statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
  367. } else if (account === undefined || account === null) {
  368. statusAvatar = <Avatar account={status.get('account')} size={48} />;
  369. } else {
  370. statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
  371. }
  372. return (
  373. <HotKeys handlers={handlers}>
  374. <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
  375. {prepend}
  376. <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
  377. <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
  378. <div className='status__info'>
  379. <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
  380. <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
  381. <div className='status__avatar'>
  382. {statusAvatar}
  383. </div>
  384. <DisplayName account={status.get('account')} others={otherAccounts} />
  385. </a>
  386. </div>
  387. <StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} collapsable />
  388. {media}
  389. {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
  390. <button className='status__content__read-more-button' onClick={this.handleClick}>
  391. <FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
  392. </button>
  393. )}
  394. <StatusActionBar status={status} account={account} {...other} />
  395. </div>
  396. </div>
  397. </HotKeys>
  398. );
  399. }
  400. }