status.jsx 22 KB

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