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.

673 lines
21 KiB

  1. import Immutable from 'immutable';
  2. import React from 'react';
  3. import { connect } from 'react-redux';
  4. import PropTypes from 'prop-types';
  5. import classNames from 'classnames';
  6. import ImmutablePropTypes from 'react-immutable-proptypes';
  7. import { createSelector } from 'reselect';
  8. import { fetchStatus } from '../../actions/statuses';
  9. import MissingIndicator from '../../components/missing_indicator';
  10. import DetailedStatus from './components/detailed_status';
  11. import ActionBar from './components/action_bar';
  12. import Column from '../ui/components/column';
  13. import Tree from 'react-tree-graph';
  14. import {
  15. favourite,
  16. unfavourite,
  17. bookmark,
  18. unbookmark,
  19. reblog,
  20. unreblog,
  21. pin,
  22. unpin,
  23. } from '../../actions/interactions';
  24. import {
  25. replyCompose,
  26. mentionCompose,
  27. directCompose,
  28. } from '../../actions/compose';
  29. import {
  30. muteStatus,
  31. unmuteStatus,
  32. deleteStatus,
  33. hideStatus,
  34. revealStatus,
  35. } from '../../actions/statuses';
  36. import {
  37. unblockAccount,
  38. unmuteAccount,
  39. } from '../../actions/accounts';
  40. import {
  41. blockDomain,
  42. unblockDomain,
  43. } from '../../actions/domain_blocks';
  44. import { initMuteModal } from '../../actions/mutes';
  45. import { initBlockModal } from '../../actions/blocks';
  46. import { initReport } from '../../actions/reports';
  47. import { makeGetStatus } from '../../selectors';
  48. import { ScrollContainer } from 'react-router-scroll-4';
  49. import ColumnBackButton from '../../components/column_back_button';
  50. import ColumnHeader from '../../components/column_header';
  51. import StatusContainer from '../../containers/status_container';
  52. import { openModal } from '../../actions/modal';
  53. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  54. import ImmutablePureComponent from 'react-immutable-pure-component';
  55. import { HotKeys } from 'react-hotkeys';
  56. import { boostModal, deleteModal } from '../../initial_state';
  57. import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
  58. import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
  59. import Icon from 'mastodon/components/icon';
  60. const messages = defineMessages({
  61. deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
  62. deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
  63. redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
  64. redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
  65. revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
  66. hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
  67. detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
  68. replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
  69. replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
  70. blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
  71. });
  72. const makeMapStateToProps = () => {
  73. const getStatus = makeGetStatus();
  74. const getAncestorsIds = createSelector([
  75. (_, { id }) => id,
  76. state => state.getIn(['contexts', 'inReplyTos']),
  77. ], (statusId, inReplyTos) => {
  78. let ancestorsIds = Immutable.List();
  79. ancestorsIds = ancestorsIds.withMutations(mutable => {
  80. let id = statusId;
  81. while (id) {
  82. mutable.unshift(id);
  83. id = inReplyTos.get(id);
  84. }
  85. });
  86. return ancestorsIds;
  87. });
  88. const getDescendantsIds = createSelector([
  89. (_, { id }) => id,
  90. state => state.getIn(['contexts', 'replies']),
  91. state => state.get('statuses'),
  92. ], (statusId, contextReplies, statuses) => {
  93. let descendantsIds = [];
  94. const ids = [statusId];
  95. while (ids.length > 0) {
  96. let id = ids.shift();
  97. const replies = contextReplies.get(id);
  98. if (statusId !== id) {
  99. descendantsIds.push(id);
  100. }
  101. if (replies) {
  102. replies.reverse().forEach(reply => {
  103. ids.unshift(reply);
  104. });
  105. }
  106. }
  107. let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
  108. if (insertAt !== -1) {
  109. descendantsIds.forEach((id, idx) => {
  110. if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
  111. descendantsIds.splice(idx, 1);
  112. descendantsIds.splice(insertAt, 0, id);
  113. insertAt += 1;
  114. }
  115. });
  116. }
  117. return Immutable.List(descendantsIds);
  118. });
  119. const getTreeData = createSelector([
  120. (_, { id }) => id,
  121. state => state.getIn(['contexts', 'replies']),
  122. state => state.get('statuses'),
  123. ], (statusId, contextReplies, statuses) => {
  124. const getMore = (id, notRoot) => {
  125. const replies = contextReplies.get(id);
  126. const cur_status = statuses.get(id);
  127. const text = cur_status.get('search_index').replace(/@\S+?\s/,'@..');
  128. return {
  129. statusId: id,
  130. name: (text.length > 16 ? text.slice(0,13) + "..." : text) + (cur_status.get('media_attachments').size > 0 ? " [图片]" : ""),
  131. children: replies ? Array.from(replies.map( i => getMore(i, true) )) : [],
  132. }
  133. }
  134. let treeData = getMore(statusId, false)
  135. return treeData;
  136. });
  137. const mapStateToProps = (state, props) => {
  138. const status = getStatus(state, { id: props.params.statusId });
  139. let ancestorsIds = Immutable.List();
  140. let descendantsIds = Immutable.List();
  141. let rootAcct;
  142. let deep;
  143. let treeData;
  144. if (status) {
  145. ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
  146. const root_status = ancestorsIds.size? getStatus(state, {id: ancestorsIds.get(0)}) : status; //error is directly visit url of non-root detailedStatus, feature!
  147. rootAcct = root_status? root_status.getIn(['account', 'acct']) : -1;
  148. if(rootAcct == '0') {
  149. descendantsIds = state.getIn(['contexts', 'replies', status.get('id')]);
  150. if(descendantsIds)
  151. descendantsIds = descendantsIds.reverse();
  152. }
  153. else {
  154. descendantsIds = getDescendantsIds(state, { id: status.get('id') });
  155. }
  156. deep = rootAcct == '0' ? ancestorsIds.size : null;
  157. treeData = rootAcct == '0' ? getTreeData(state, {id: status.get('id')}) : null;
  158. }
  159. return {
  160. status,
  161. deep,
  162. ancestorsIds,
  163. descendantsIds,
  164. treeData,
  165. askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
  166. domain: state.getIn(['meta', 'domain']),
  167. };
  168. };
  169. return mapStateToProps;
  170. };
  171. export default @injectIntl
  172. @connect(makeMapStateToProps)
  173. class Status extends ImmutablePureComponent {
  174. static contextTypes = {
  175. router: PropTypes.object,
  176. };
  177. static propTypes = {
  178. params: PropTypes.object.isRequired,
  179. dispatch: PropTypes.func.isRequired,
  180. status: ImmutablePropTypes.map,
  181. ancestorsIds: ImmutablePropTypes.list,
  182. descendantsIds: ImmutablePropTypes.list,
  183. intl: PropTypes.object.isRequired,
  184. askReplyConfirmation: PropTypes.bool,
  185. multiColumn: PropTypes.bool,
  186. domain: PropTypes.string.isRequired,
  187. };
  188. state = {
  189. fullscreen: false,
  190. showMedia: defaultMediaVisibility(this.props.status),
  191. loadedStatusId: undefined,
  192. showTree: false,
  193. svgWidth: 400,
  194. activeNode: null
  195. };
  196. componentWillMount () {
  197. this.props.dispatch(fetchStatus(this.props.params.statusId));
  198. }
  199. componentDidMount () {
  200. attachFullscreenListener(this.onFullScreenChange);
  201. }
  202. componentWillReceiveProps (nextProps) {
  203. if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
  204. this._scrolledIntoView = false;
  205. this.props.dispatch(fetchStatus(nextProps.params.statusId));
  206. }
  207. if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
  208. this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
  209. }
  210. }
  211. handleToggleMediaVisibility = () => {
  212. this.setState({ showMedia: !this.state.showMedia });
  213. }
  214. handleFavouriteClick = (status) => {
  215. if (status.get('favourited')) {
  216. this.props.dispatch(unfavourite(status));
  217. } else {
  218. this.props.dispatch(favourite(status));
  219. }
  220. }
  221. handlePin = (status) => {
  222. if (status.get('pinned')) {
  223. this.props.dispatch(unpin(status));
  224. } else {
  225. this.props.dispatch(pin(status));
  226. }
  227. }
  228. handleReplyClick = (status) => {
  229. let { askReplyConfirmation, dispatch, intl } = this.props;
  230. if (askReplyConfirmation) {
  231. dispatch(openModal('CONFIRM', {
  232. message: intl.formatMessage(messages.replyMessage),
  233. confirm: intl.formatMessage(messages.replyConfirm),
  234. onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
  235. }));
  236. } else {
  237. dispatch(replyCompose(status, this.context.router.history));
  238. }
  239. }
  240. handleModalReblog = (status) => {
  241. this.props.dispatch(reblog(status));
  242. }
  243. handleReblogClick = (status, e) => {
  244. if (status.get('reblogged')) {
  245. this.props.dispatch(unreblog(status));
  246. } else {
  247. if ((e && e.shiftKey) || !boostModal) {
  248. this.handleModalReblog(status);
  249. } else {
  250. this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
  251. }
  252. }
  253. }
  254. handleBookmarkClick = (status) => {
  255. if (status.get('bookmarked')) {
  256. this.props.dispatch(unbookmark(status));
  257. } else {
  258. this.props.dispatch(bookmark(status));
  259. }
  260. }
  261. handleDeleteClick = (status, history, withRedraft = false) => {
  262. const { dispatch, intl } = this.props;
  263. if (!deleteModal) {
  264. dispatch(deleteStatus(status.get('id'), history, withRedraft));
  265. } else {
  266. dispatch(openModal('CONFIRM', {
  267. message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
  268. confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
  269. onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
  270. }));
  271. }
  272. }
  273. handleDirectClick = (account, router) => {
  274. this.props.dispatch(directCompose(account, router));
  275. }
  276. handleMentionClick = (account, router) => {
  277. this.props.dispatch(mentionCompose(account, router));
  278. }
  279. handleOpenMedia = (media, index) => {
  280. this.props.dispatch(openModal('MEDIA', { media, index }));
  281. }
  282. handleOpenVideo = (media, options) => {
  283. this.props.dispatch(openModal('VIDEO', { media, options }));
  284. }
  285. handleHotkeyOpenMedia = e => {
  286. const status = this._properStatus();
  287. e.preventDefault();
  288. if (status.get('media_attachments').size > 0) {
  289. if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
  290. // TODO: toggle play/paused?
  291. } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
  292. this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
  293. } else {
  294. this.handleOpenMedia(status.get('media_attachments'), 0);
  295. }
  296. }
  297. }
  298. handleMuteClick = (account) => {
  299. this.props.dispatch(initMuteModal(account));
  300. }
  301. handleConversationMuteClick = (status) => {
  302. if (status.get('muted')) {
  303. this.props.dispatch(unmuteStatus(status.get('id')));
  304. } else {
  305. this.props.dispatch(muteStatus(status.get('id')));
  306. }
  307. }
  308. handleToggleHidden = (status) => {
  309. if (status.get('hidden')) {
  310. this.props.dispatch(revealStatus(status.get('id')));
  311. } else {
  312. this.props.dispatch(hideStatus(status.get('id')));
  313. }
  314. }
  315. handleToggleAll = () => {
  316. const { status, ancestorsIds, descendantsIds } = this.props;
  317. const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
  318. if (status.get('hidden')) {
  319. this.props.dispatch(revealStatus(statusIds));
  320. } else {
  321. this.props.dispatch(hideStatus(statusIds));
  322. }
  323. }
  324. handleShowTree = () => {
  325. this.setState({
  326. activeNode: null,
  327. showTree: !this.state.showTree
  328. });
  329. }
  330. handleBlockClick = (status) => {
  331. const { dispatch } = this.props;
  332. const account = status.get('account');
  333. dispatch(initBlockModal(account));
  334. }
  335. handleReport = (status) => {
  336. this.props.dispatch(initReport(status.get('account'), status));
  337. }
  338. handleEmbed = (status) => {
  339. this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
  340. }
  341. handleUnmuteClick = account => {
  342. this.props.dispatch(unmuteAccount(account.get('id')));
  343. }
  344. handleUnblockClick = account => {
  345. this.props.dispatch(unblockAccount(account.get('id')));
  346. }
  347. handleBlockDomainClick = domain => {
  348. this.props.dispatch(openModal('CONFIRM', {
  349. message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
  350. confirm: this.props.intl.formatMessage(messages.blockDomainConfirm),
  351. onConfirm: () => this.props.dispatch(blockDomain(domain)),
  352. }));
  353. }
  354. handleUnblockDomainClick = domain => {
  355. this.props.dispatch(unblockDomain(domain));
  356. }
  357. handleHotkeyMoveUp = () => {
  358. this.handleMoveUp(this.props.status.get('id'));
  359. }
  360. handleHotkeyMoveDown = () => {
  361. this.handleMoveDown(this.props.status.get('id'));
  362. }
  363. handleHotkeyReply = e => {
  364. e.preventDefault();
  365. this.handleReplyClick(this.props.status);
  366. }
  367. handleHotkeyFavourite = () => {
  368. this.handleFavouriteClick(this.props.status);
  369. }
  370. handleHotkeyBoost = () => {
  371. this.handleReblogClick(this.props.status);
  372. }
  373. handleHotkeyMention = e => {
  374. e.preventDefault();
  375. this.handleMentionClick(this.props.status.get('account'));
  376. }
  377. handleHotkeyOpenProfile = () => {
  378. this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
  379. }
  380. handleHotkeyToggleHidden = () => {
  381. this.handleToggleHidden(this.props.status);
  382. }
  383. handleHotkeyToggleSensitive = () => {
  384. this.handleToggleMediaVisibility();
  385. }
  386. handleMoveUp = id => {
  387. const { status, ancestorsIds, descendantsIds } = this.props;
  388. if (id === status.get('id')) {
  389. this._selectChild(ancestorsIds.size - 1, true);
  390. } else {
  391. let index = ancestorsIds.indexOf(id);
  392. if (index === -1) {
  393. index = descendantsIds.indexOf(id);
  394. this._selectChild(ancestorsIds.size + index, true);
  395. } else {
  396. this._selectChild(index - 1, true);
  397. }
  398. }
  399. }
  400. handleMoveDown = id => {
  401. const { status, ancestorsIds, descendantsIds } = this.props;
  402. if (id === status.get('id')) {
  403. this._selectChild(ancestorsIds.size + 1, false);
  404. } else {
  405. let index = ancestorsIds.indexOf(id);
  406. if (index === -1) {
  407. index = descendantsIds.indexOf(id);
  408. this._selectChild(ancestorsIds.size + index + 2, false);
  409. } else {
  410. this._selectChild(index + 1, false);
  411. }
  412. }
  413. }
  414. _selectChild (index, align_top) {
  415. const container = this.node;
  416. const element = container.querySelectorAll('.focusable')[index];
  417. if (element) {
  418. if (align_top && container.scrollTop > element.offsetTop) {
  419. element.scrollIntoView(true);
  420. } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
  421. element.scrollIntoView(false);
  422. }
  423. element.focus();
  424. }
  425. }
  426. renderChildren (list, type) {
  427. const { deep } = this.props;
  428. return list.map((id,idx) => (
  429. <StatusContainer
  430. key={id}
  431. id={id}
  432. onMoveUp={this.handleMoveUp}
  433. onMoveDown={this.handleMoveDown}
  434. contextType='thread'
  435. deep={deep==null? null : (type == 'ance'? idx : deep+1)}
  436. tree_type={deep==null? null : type}
  437. />
  438. ));
  439. }
  440. setRef = c => {
  441. this.node = c;
  442. }
  443. componentDidUpdate () {
  444. if (this._scrolledIntoView) {
  445. return;
  446. }
  447. const { status, ancestorsIds } = this.props;
  448. if (status && ancestorsIds && ancestorsIds.size > 0) {
  449. const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
  450. window.requestAnimationFrame(() => {
  451. element.scrollIntoView(true);
  452. });
  453. this._scrolledIntoView = true;
  454. }
  455. }
  456. componentWillUnmount () {
  457. detachFullscreenListener(this.onFullScreenChange);
  458. }
  459. onFullScreenChange = () => {
  460. this.setState({ fullscreen: isFullscreen() });
  461. }
  462. handleNodeClick = (ev, node) => {
  463. if (!this.context.router) {
  464. return;
  465. }
  466. const { status } = this.props;
  467. this.context.router.history.push(`/statuses/${node}`);
  468. }
  469. render () {
  470. let ancestors, descendants;
  471. const { shouldUpdateScroll, status, deep, ancestorsIds, descendantsIds, treeData, intl, domain, multiColumn } = this.props;
  472. const { fullscreen, showTree, svgWidth, activeNode } = this.state;
  473. if (status === null) {
  474. return (
  475. <Column>
  476. <ColumnBackButton multiColumn={multiColumn} />
  477. <MissingIndicator />
  478. </Column>
  479. );
  480. }
  481. if (ancestorsIds && ancestorsIds.size > 0) {
  482. ancestors = <div>{this.renderChildren(ancestorsIds, 'ance')}</div>;
  483. }
  484. if (descendantsIds && descendantsIds.size > 0) {
  485. descendants = <div>{this.renderChildren(descendantsIds, 'desc')}</div>;
  486. }
  487. const handlers = {
  488. moveUp: this.handleHotkeyMoveUp,
  489. moveDown: this.handleHotkeyMoveDown,
  490. reply: this.handleHotkeyReply,
  491. favourite: this.handleHotkeyFavourite,
  492. boost: this.handleHotkeyBoost,
  493. mention: this.handleHotkeyMention,
  494. openProfile: this.handleHotkeyOpenProfile,
  495. toggleHidden: this.handleHotkeyToggleHidden,
  496. toggleSensitive: this.handleHotkeyToggleSensitive,
  497. openMedia: this.handleHotkeyOpenMedia,
  498. };
  499. return (
  500. <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.detailedStatus)}>
  501. <ColumnHeader
  502. showBackButton
  503. multiColumn={multiColumn}
  504. extraButton={(
  505. <button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={deep ==null ? this.handleToggleAll : this.handleShowTree} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
  506. )}
  507. />
  508. <ScrollContainer scrollKey='thread' shouldUpdateScroll={shouldUpdateScroll}>
  509. <div className={classNames('scrollable', { fullscreen }, {'tree':deep!=null})} ref={this.setRef}>
  510. {ancestors}
  511. <HotKeys handlers={handlers}>
  512. <div className={classNames('focusable', 'detailed-status__wrapper')} tabIndex='0' aria-label={textForScreenReader(intl, status, false)} ref={e=>{if(e) this.setState({svgWidth: e.clientWidth})}}>
  513. {showTree ?
  514. <Tree
  515. data={treeData}
  516. height={800}
  517. width={svgWidth+80}
  518. animated
  519. keyProp = {"statusId"}
  520. textProps={{
  521. x: -9,
  522. y: -7
  523. }}
  524. gProps={{
  525. className: 'node',
  526. onClick: this.handleNodeClick
  527. }}
  528. svgProps={{
  529. className: 'tree-svg'
  530. }}/>
  531. :
  532. <DetailedStatus
  533. <<<<<<< HEAD
  534. key={`detail-${status.get('id')}`}
  535. =======
  536. key={`details-${status.get('id')}`}
  537. >>>>>>> master
  538. status={status}
  539. deep={deep}
  540. onOpenVideo={this.handleOpenVideo}
  541. onOpenMedia={this.handleOpenMedia}
  542. onToggleHidden={this.handleToggleHidden}
  543. domain={domain}
  544. showMedia={this.state.showMedia}
  545. onToggleMediaVisibility={this.handleToggleMediaVisibility}
  546. />
  547. }
  548. <ActionBar
  549. key={`action-bar-${status.get('id')}`}
  550. status={status}
  551. onReply={this.handleReplyClick}
  552. onFavourite={this.handleFavouriteClick}
  553. onReblog={this.handleReblogClick}
  554. onBookmark={this.handleBookmarkClick}
  555. onDelete={this.handleDeleteClick}
  556. onDirect={this.handleDirectClick}
  557. onMention={this.handleMentionClick}
  558. onMute={this.handleMuteClick}
  559. onUnmute={this.handleUnmuteClick}
  560. onMuteConversation={this.handleConversationMuteClick}
  561. onBlock={this.handleBlockClick}
  562. onUnblock={this.handleUnblockClick}
  563. onBlockDomain={this.handleBlockDomainClick}
  564. onUnblockDomain={this.handleUnblockDomainClick}
  565. onReport={this.handleReport}
  566. onPin={this.handlePin}
  567. onEmbed={this.handleEmbed}
  568. />
  569. </div>
  570. </HotKeys>
  571. {descendants}
  572. </div>
  573. </ScrollContainer>
  574. </Column>
  575. );
  576. }
  577. }