The code powering m.abunchtell.com https://m.abunchtell.com
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

469 linhas
16 KiB

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