status.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import Avatar from './avatar';
  5. import AvatarOverlay from './avatar_overlay';
  6. import RelativeTimestamp from './relative_timestamp';
  7. import DisplayName from './display_name';
  8. import StatusContent from './status_content';
  9. import StatusActionBar from './status_action_bar';
  10. import AttachmentList from './attachment_list';
  11. import Card from '../features/status/components/card';
  12. import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
  13. import ImmutablePureComponent from 'react-immutable-pure-component';
  14. import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
  15. import { HotKeys } from 'react-hotkeys';
  16. import classNames from 'classnames';
  17. import Icon from 'mastodon/components/icon';
  18. import { displayMedia } from '../initial_state';
  19. import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
  20. // We use the component (and not the container) since we do not want
  21. // to use the progress bar to show download progress
  22. import Bundle from '../features/ui/components/bundle';
  23. export const textForScreenReader = (intl, status, rebloggedByText = false) => {
  24. const displayName = status.getIn(['account', 'display_name']);
  25. const values = [
  26. displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
  27. status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
  28. intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
  29. status.getIn(['account', 'acct']),
  30. ];
  31. if (rebloggedByText) {
  32. values.push(rebloggedByText);
  33. }
  34. return values.join(', ');
  35. };
  36. export const defaultMediaVisibility = (status) => {
  37. if (!status) {
  38. return undefined;
  39. }
  40. if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  41. status = status.get('reblog');
  42. }
  43. return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
  44. };
  45. const messages = defineMessages({
  46. public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
  47. unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
  48. private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
  49. direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
  50. edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
  51. });
  52. export default @injectIntl
  53. class Status extends ImmutablePureComponent {
  54. static contextTypes = {
  55. router: PropTypes.object,
  56. };
  57. static propTypes = {
  58. status: ImmutablePropTypes.map,
  59. account: ImmutablePropTypes.map,
  60. onClick: PropTypes.func,
  61. onReply: PropTypes.func,
  62. onFavourite: PropTypes.func,
  63. onReblog: PropTypes.func,
  64. onDelete: PropTypes.func,
  65. onDirect: PropTypes.func,
  66. onMention: PropTypes.func,
  67. onPin: PropTypes.func,
  68. onOpenMedia: PropTypes.func,
  69. onOpenVideo: PropTypes.func,
  70. onBlock: PropTypes.func,
  71. onAddFilter: PropTypes.func,
  72. onEmbed: PropTypes.func,
  73. onHeightChange: PropTypes.func,
  74. onToggleHidden: PropTypes.func,
  75. onToggleCollapsed: PropTypes.func,
  76. onTranslate: PropTypes.func,
  77. onInteractionModal: PropTypes.func,
  78. muted: PropTypes.bool,
  79. hidden: PropTypes.bool,
  80. unread: PropTypes.bool,
  81. onMoveUp: PropTypes.func,
  82. onMoveDown: PropTypes.func,
  83. showThread: PropTypes.bool,
  84. getScrollPosition: PropTypes.func,
  85. updateScrollBottom: PropTypes.func,
  86. cacheMediaWidth: PropTypes.func,
  87. cachedMediaWidth: PropTypes.number,
  88. scrollKey: PropTypes.string,
  89. deployPictureInPicture: PropTypes.func,
  90. pictureInPicture: ImmutablePropTypes.contains({
  91. inUse: PropTypes.bool,
  92. available: PropTypes.bool,
  93. }),
  94. };
  95. // Avoid checking props that are functions (and whose equality will always
  96. // evaluate to false. See react-immutable-pure-component for usage.
  97. updateOnProps = [
  98. 'status',
  99. 'account',
  100. 'muted',
  101. 'hidden',
  102. 'unread',
  103. 'pictureInPicture',
  104. ];
  105. state = {
  106. showMedia: defaultMediaVisibility(this.props.status),
  107. statusId: undefined,
  108. forceFilter: undefined,
  109. };
  110. static getDerivedStateFromProps(nextProps, prevState) {
  111. if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
  112. return {
  113. showMedia: defaultMediaVisibility(nextProps.status),
  114. statusId: nextProps.status.get('id'),
  115. };
  116. } else {
  117. return null;
  118. }
  119. }
  120. handleToggleMediaVisibility = () => {
  121. this.setState({ showMedia: !this.state.showMedia });
  122. }
  123. handleClick = e => {
  124. if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
  125. return;
  126. }
  127. if (e) {
  128. e.preventDefault();
  129. }
  130. this.handleHotkeyOpen();
  131. }
  132. handlePrependAccountClick = e => {
  133. this.handleAccountClick(e, false);
  134. }
  135. handleAccountClick = (e, proper = true) => {
  136. if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
  137. return;
  138. }
  139. if (e) {
  140. e.preventDefault();
  141. }
  142. this._openProfile(proper);
  143. }
  144. handleExpandedToggle = () => {
  145. this.props.onToggleHidden(this._properStatus());
  146. }
  147. handleCollapsedToggle = isCollapsed => {
  148. this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
  149. }
  150. handleTranslate = () => {
  151. this.props.onTranslate(this._properStatus());
  152. }
  153. renderLoadingMediaGallery () {
  154. return <div className='media-gallery' style={{ height: '110px' }} />;
  155. }
  156. renderLoadingVideoPlayer () {
  157. return <div className='video-player' style={{ height: '110px' }} />;
  158. }
  159. renderLoadingAudioPlayer () {
  160. return <div className='audio-player' style={{ height: '110px' }} />;
  161. }
  162. handleOpenVideo = (options) => {
  163. const status = this._properStatus();
  164. this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
  165. }
  166. handleOpenMedia = (media, index) => {
  167. this.props.onOpenMedia(this._properStatus().get('id'), media, index);
  168. }
  169. handleHotkeyOpenMedia = e => {
  170. const { onOpenMedia, onOpenVideo } = this.props;
  171. const status = this._properStatus();
  172. e.preventDefault();
  173. if (status.get('media_attachments').size > 0) {
  174. if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
  175. onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
  176. } else {
  177. onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
  178. }
  179. }
  180. }
  181. handleDeployPictureInPicture = (type, mediaProps) => {
  182. const { deployPictureInPicture } = this.props;
  183. const status = this._properStatus();
  184. deployPictureInPicture(status, type, mediaProps);
  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. if (this.props.onClick) {
  202. this.props.onClick();
  203. return;
  204. }
  205. const { router } = this.context;
  206. const status = this._properStatus();
  207. if (!router) {
  208. return;
  209. }
  210. router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
  211. }
  212. handleHotkeyOpenProfile = () => {
  213. this._openProfile();
  214. }
  215. _openProfile = (proper = true) => {
  216. const { router } = this.context;
  217. const status = proper ? this._properStatus() : this.props.status;
  218. if (!router) {
  219. return;
  220. }
  221. router.history.push(`/@${status.getIn(['account', 'acct'])}`);
  222. }
  223. handleHotkeyMoveUp = e => {
  224. this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  225. }
  226. handleHotkeyMoveDown = e => {
  227. this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
  228. }
  229. handleHotkeyToggleHidden = () => {
  230. this.props.onToggleHidden(this._properStatus());
  231. }
  232. handleHotkeyToggleSensitive = () => {
  233. this.handleToggleMediaVisibility();
  234. }
  235. handleUnfilterClick = e => {
  236. this.setState({ forceFilter: false });
  237. e.preventDefault();
  238. }
  239. handleFilterClick = () => {
  240. this.setState({ forceFilter: true });
  241. }
  242. _properStatus () {
  243. const { status } = this.props;
  244. if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  245. return status.get('reblog');
  246. } else {
  247. return status;
  248. }
  249. }
  250. handleRef = c => {
  251. this.node = c;
  252. }
  253. render () {
  254. let media = null;
  255. let statusAvatar, prepend, rebloggedByText;
  256. const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props;
  257. let { status, account, ...other } = this.props;
  258. if (status === null) {
  259. return null;
  260. }
  261. const handlers = this.props.muted ? {} : {
  262. reply: this.handleHotkeyReply,
  263. favourite: this.handleHotkeyFavourite,
  264. boost: this.handleHotkeyBoost,
  265. mention: this.handleHotkeyMention,
  266. open: this.handleHotkeyOpen,
  267. openProfile: this.handleHotkeyOpenProfile,
  268. moveUp: this.handleHotkeyMoveUp,
  269. moveDown: this.handleHotkeyMoveDown,
  270. toggleHidden: this.handleHotkeyToggleHidden,
  271. toggleSensitive: this.handleHotkeyToggleSensitive,
  272. openMedia: this.handleHotkeyOpenMedia,
  273. };
  274. if (hidden) {
  275. return (
  276. <HotKeys handlers={handlers}>
  277. <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
  278. <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
  279. <span>{status.get('content')}</span>
  280. </div>
  281. </HotKeys>
  282. );
  283. }
  284. const matchedFilters = status.get('matched_filters');
  285. if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
  286. const minHandlers = this.props.muted ? {} : {
  287. moveUp: this.handleHotkeyMoveUp,
  288. moveDown: this.handleHotkeyMoveDown,
  289. };
  290. return (
  291. <HotKeys handlers={minHandlers}>
  292. <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
  293. <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
  294. {' '}
  295. <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
  296. <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
  297. </button>
  298. </div>
  299. </HotKeys>
  300. );
  301. }
  302. if (featured) {
  303. prepend = (
  304. <div className='status__prepend'>
  305. <div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
  306. <FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
  307. </div>
  308. );
  309. } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
  310. const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
  311. prepend = (
  312. <div className='status__prepend'>
  313. <div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
  314. <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
  315. </div>
  316. );
  317. rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
  318. account = status.get('account');
  319. status = status.get('reblog');
  320. } else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
  321. const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
  322. prepend = (
  323. <div className='status__prepend'>
  324. <div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
  325. <FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
  326. </div>
  327. );
  328. }
  329. if (pictureInPicture.get('inUse')) {
  330. media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
  331. } else if (status.get('media_attachments').size > 0) {
  332. if (this.props.muted) {
  333. media = (
  334. <AttachmentList
  335. compact
  336. media={status.get('media_attachments')}
  337. />
  338. );
  339. } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
  340. const attachment = status.getIn(['media_attachments', 0]);
  341. media = (
  342. <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
  343. {Component => (
  344. <Component
  345. src={attachment.get('url')}
  346. alt={attachment.get('description')}
  347. poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
  348. backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
  349. foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
  350. accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
  351. duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
  352. width={this.props.cachedMediaWidth}
  353. height={110}
  354. cacheWidth={this.props.cacheMediaWidth}
  355. deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
  356. sensitive={status.get('sensitive')}
  357. blurhash={attachment.get('blurhash')}
  358. visible={this.state.showMedia}
  359. onToggleVisibility={this.handleToggleMediaVisibility}
  360. />
  361. )}
  362. </Bundle>
  363. );
  364. } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
  365. const attachment = status.getIn(['media_attachments', 0]);
  366. media = (
  367. <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
  368. {Component => (
  369. <Component
  370. preview={attachment.get('preview_url')}
  371. frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
  372. blurhash={attachment.get('blurhash')}
  373. src={attachment.get('url')}
  374. alt={attachment.get('description')}
  375. width={this.props.cachedMediaWidth}
  376. height={110}
  377. inline
  378. sensitive={status.get('sensitive')}
  379. onOpenVideo={this.handleOpenVideo}
  380. cacheWidth={this.props.cacheMediaWidth}
  381. deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
  382. visible={this.state.showMedia}
  383. onToggleVisibility={this.handleToggleMediaVisibility}
  384. />
  385. )}
  386. </Bundle>
  387. );
  388. } else {
  389. media = (
  390. <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
  391. {Component => (
  392. <Component
  393. media={status.get('media_attachments')}
  394. sensitive={status.get('sensitive')}
  395. height={110}
  396. onOpenMedia={this.handleOpenMedia}
  397. cacheWidth={this.props.cacheMediaWidth}
  398. defaultWidth={this.props.cachedMediaWidth}
  399. visible={this.state.showMedia}
  400. onToggleVisibility={this.handleToggleMediaVisibility}
  401. />
  402. )}
  403. </Bundle>
  404. );
  405. }
  406. } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
  407. media = (
  408. <Card
  409. onOpenMedia={this.handleOpenMedia}
  410. card={status.get('card')}
  411. compact
  412. cacheWidth={this.props.cacheMediaWidth}
  413. defaultWidth={this.props.cachedMediaWidth}
  414. sensitive={status.get('sensitive')}
  415. />
  416. );
  417. }
  418. if (account === undefined || account === null) {
  419. statusAvatar = <Avatar account={status.get('account')} size={46} />;
  420. } else {
  421. statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
  422. }
  423. const visibilityIconInfo = {
  424. 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
  425. 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
  426. 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
  427. 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
  428. };
  429. const visibilityIcon = visibilityIconInfo[status.get('visibility')];
  430. return (
  431. <HotKeys handlers={handlers}>
  432. <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
  433. {prepend}
  434. <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
  435. <div className='status__info'>
  436. <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
  437. <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
  438. <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
  439. </a>
  440. <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
  441. <div className='status__avatar'>
  442. {statusAvatar}
  443. </div>
  444. <DisplayName account={status.get('account')} />
  445. </a>
  446. </div>
  447. <StatusContent
  448. status={status}
  449. onClick={this.handleClick}
  450. expanded={!status.get('hidden')}
  451. onExpandedToggle={this.handleExpandedToggle}
  452. onTranslate={this.handleTranslate}
  453. collapsable
  454. onCollapsedToggle={this.handleCollapsedToggle}
  455. />
  456. {media}
  457. <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
  458. </div>
  459. </div>
  460. </HotKeys>
  461. );
  462. }
  463. }