The code powering m.abunchtell.com https://m.abunchtell.com
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

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