The code powering m.abunchtell.com https://m.abunchtell.com
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.
 
 
 
 

472 lines
14 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 { fetchStatus } from '../../actions/statuses';
  8. import MissingIndicator from '../../components/missing_indicator';
  9. import DetailedStatus from './components/detailed_status';
  10. import ActionBar from './components/action_bar';
  11. import Column from '../ui/components/column';
  12. import {
  13. favourite,
  14. unfavourite,
  15. reblog,
  16. unreblog,
  17. pin,
  18. unpin,
  19. } from '../../actions/interactions';
  20. import {
  21. replyCompose,
  22. mentionCompose,
  23. directCompose,
  24. } from '../../actions/compose';
  25. import { blockAccount } from '../../actions/accounts';
  26. import {
  27. muteStatus,
  28. unmuteStatus,
  29. deleteStatus,
  30. hideStatus,
  31. revealStatus,
  32. } from '../../actions/statuses';
  33. import { initMuteModal } from '../../actions/mutes';
  34. import { initReport } from '../../actions/reports';
  35. import { makeGetStatus } from '../../selectors';
  36. import { ScrollContainer } from 'react-router-scroll-4';
  37. import ColumnBackButton from '../../components/column_back_button';
  38. import ColumnHeader from '../../components/column_header';
  39. import StatusContainer from '../../containers/status_container';
  40. import { openModal } from '../../actions/modal';
  41. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  42. import ImmutablePureComponent from 'react-immutable-pure-component';
  43. import { HotKeys } from 'react-hotkeys';
  44. import { boostModal, deleteModal } from '../../initial_state';
  45. import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
  46. import { textForScreenReader } from '../../components/status';
  47. const messages = defineMessages({
  48. deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
  49. deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
  50. redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
  51. 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.' },
  52. blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
  53. revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
  54. hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
  55. detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
  56. replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
  57. replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
  58. });
  59. const makeMapStateToProps = () => {
  60. const getStatus = makeGetStatus();
  61. const mapStateToProps = (state, props) => {
  62. const status = getStatus(state, { id: props.params.statusId });
  63. let ancestorsIds = Immutable.List();
  64. let descendantsIds = Immutable.List();
  65. if (status) {
  66. ancestorsIds = ancestorsIds.withMutations(mutable => {
  67. let id = status.get('in_reply_to_id');
  68. while (id) {
  69. mutable.unshift(id);
  70. id = state.getIn(['contexts', 'inReplyTos', id]);
  71. }
  72. });
  73. descendantsIds = descendantsIds.withMutations(mutable => {
  74. const ids = [status.get('id')];
  75. while (ids.length > 0) {
  76. let id = ids.shift();
  77. const replies = state.getIn(['contexts', 'replies', id]);
  78. if (status.get('id') !== id) {
  79. mutable.push(id);
  80. }
  81. if (replies) {
  82. replies.reverse().forEach(reply => {
  83. ids.unshift(reply);
  84. });
  85. }
  86. }
  87. });
  88. }
  89. return {
  90. status,
  91. ancestorsIds,
  92. descendantsIds,
  93. askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
  94. domain: state.getIn(['meta', 'domain']),
  95. };
  96. };
  97. return mapStateToProps;
  98. };
  99. export default @injectIntl
  100. @connect(makeMapStateToProps)
  101. class Status extends ImmutablePureComponent {
  102. static contextTypes = {
  103. router: PropTypes.object,
  104. };
  105. static propTypes = {
  106. params: PropTypes.object.isRequired,
  107. dispatch: PropTypes.func.isRequired,
  108. status: ImmutablePropTypes.map,
  109. ancestorsIds: ImmutablePropTypes.list,
  110. descendantsIds: ImmutablePropTypes.list,
  111. intl: PropTypes.object.isRequired,
  112. askReplyConfirmation: PropTypes.bool,
  113. domain: PropTypes.string.isRequired,
  114. };
  115. state = {
  116. fullscreen: false,
  117. };
  118. componentWillMount () {
  119. this.props.dispatch(fetchStatus(this.props.params.statusId));
  120. }
  121. componentDidMount () {
  122. attachFullscreenListener(this.onFullScreenChange);
  123. }
  124. componentWillReceiveProps (nextProps) {
  125. if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
  126. this._scrolledIntoView = false;
  127. this.props.dispatch(fetchStatus(nextProps.params.statusId));
  128. }
  129. }
  130. handleFavouriteClick = (status) => {
  131. if (status.get('favourited')) {
  132. this.props.dispatch(unfavourite(status));
  133. } else {
  134. this.props.dispatch(favourite(status));
  135. }
  136. }
  137. handlePin = (status) => {
  138. if (status.get('pinned')) {
  139. this.props.dispatch(unpin(status));
  140. } else {
  141. this.props.dispatch(pin(status));
  142. }
  143. }
  144. handleReplyClick = (status) => {
  145. let { askReplyConfirmation, dispatch, intl } = this.props;
  146. if (askReplyConfirmation) {
  147. dispatch(openModal('CONFIRM', {
  148. message: intl.formatMessage(messages.replyMessage),
  149. confirm: intl.formatMessage(messages.replyConfirm),
  150. onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
  151. }));
  152. } else {
  153. dispatch(replyCompose(status, this.context.router.history));
  154. }
  155. }
  156. handleModalReblog = (status) => {
  157. this.props.dispatch(reblog(status));
  158. }
  159. handleReblogClick = (status, e) => {
  160. if (status.get('reblogged')) {
  161. this.props.dispatch(unreblog(status));
  162. } else {
  163. if ((e && e.shiftKey) || !boostModal) {
  164. this.handleModalReblog(status);
  165. } else {
  166. this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
  167. }
  168. }
  169. }
  170. handleDeleteClick = (status, history, withRedraft = false) => {
  171. const { dispatch, intl } = this.props;
  172. if (!deleteModal) {
  173. dispatch(deleteStatus(status.get('id'), history, withRedraft));
  174. } else {
  175. dispatch(openModal('CONFIRM', {
  176. message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
  177. confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
  178. onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
  179. }));
  180. }
  181. }
  182. handleDirectClick = (account, router) => {
  183. this.props.dispatch(directCompose(account, router));
  184. }
  185. handleMentionClick = (account, router) => {
  186. this.props.dispatch(mentionCompose(account, router));
  187. }
  188. handleOpenMedia = (media, index) => {
  189. this.props.dispatch(openModal('MEDIA', { media, index }));
  190. }
  191. handleOpenVideo = (media, time) => {
  192. this.props.dispatch(openModal('VIDEO', { media, time }));
  193. }
  194. handleMuteClick = (account) => {
  195. this.props.dispatch(initMuteModal(account));
  196. }
  197. handleConversationMuteClick = (status) => {
  198. if (status.get('muted')) {
  199. this.props.dispatch(unmuteStatus(status.get('id')));
  200. } else {
  201. this.props.dispatch(muteStatus(status.get('id')));
  202. }
  203. }
  204. handleToggleHidden = (status) => {
  205. if (status.get('hidden')) {
  206. this.props.dispatch(revealStatus(status.get('id')));
  207. } else {
  208. this.props.dispatch(hideStatus(status.get('id')));
  209. }
  210. }
  211. handleToggleAll = () => {
  212. const { status, ancestorsIds, descendantsIds } = this.props;
  213. const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
  214. if (status.get('hidden')) {
  215. this.props.dispatch(revealStatus(statusIds));
  216. } else {
  217. this.props.dispatch(hideStatus(statusIds));
  218. }
  219. }
  220. handleBlockClick = (account) => {
  221. const { dispatch, intl } = this.props;
  222. dispatch(openModal('CONFIRM', {
  223. message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
  224. confirm: intl.formatMessage(messages.blockConfirm),
  225. onConfirm: () => dispatch(blockAccount(account.get('id'))),
  226. }));
  227. }
  228. handleReport = (status) => {
  229. this.props.dispatch(initReport(status.get('account'), status));
  230. }
  231. handleEmbed = (status) => {
  232. this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
  233. }
  234. handleHotkeyMoveUp = () => {
  235. this.handleMoveUp(this.props.status.get('id'));
  236. }
  237. handleHotkeyMoveDown = () => {
  238. this.handleMoveDown(this.props.status.get('id'));
  239. }
  240. handleHotkeyReply = e => {
  241. e.preventDefault();
  242. this.handleReplyClick(this.props.status);
  243. }
  244. handleHotkeyFavourite = () => {
  245. this.handleFavouriteClick(this.props.status);
  246. }
  247. handleHotkeyBoost = () => {
  248. this.handleReblogClick(this.props.status);
  249. }
  250. handleHotkeyMention = e => {
  251. e.preventDefault();
  252. this.handleMentionClick(this.props.status.get('account'));
  253. }
  254. handleHotkeyOpenProfile = () => {
  255. this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
  256. }
  257. handleHotkeyToggleHidden = () => {
  258. this.handleToggleHidden(this.props.status);
  259. }
  260. handleMoveUp = id => {
  261. const { status, ancestorsIds, descendantsIds } = this.props;
  262. if (id === status.get('id')) {
  263. this._selectChild(ancestorsIds.size - 1);
  264. } else {
  265. let index = ancestorsIds.indexOf(id);
  266. if (index === -1) {
  267. index = descendantsIds.indexOf(id);
  268. this._selectChild(ancestorsIds.size + index);
  269. } else {
  270. this._selectChild(index - 1);
  271. }
  272. }
  273. }
  274. handleMoveDown = id => {
  275. const { status, ancestorsIds, descendantsIds } = this.props;
  276. if (id === status.get('id')) {
  277. this._selectChild(ancestorsIds.size + 1);
  278. } else {
  279. let index = ancestorsIds.indexOf(id);
  280. if (index === -1) {
  281. index = descendantsIds.indexOf(id);
  282. this._selectChild(ancestorsIds.size + index + 2);
  283. } else {
  284. this._selectChild(index + 1);
  285. }
  286. }
  287. }
  288. _selectChild (index) {
  289. const element = this.node.querySelectorAll('.focusable')[index];
  290. if (element) {
  291. element.focus();
  292. }
  293. }
  294. renderChildren (list) {
  295. return list.map(id => (
  296. <StatusContainer
  297. key={id}
  298. id={id}
  299. onMoveUp={this.handleMoveUp}
  300. onMoveDown={this.handleMoveDown}
  301. contextType='thread'
  302. />
  303. ));
  304. }
  305. setRef = c => {
  306. this.node = c;
  307. }
  308. componentDidUpdate () {
  309. if (this._scrolledIntoView) {
  310. return;
  311. }
  312. const { status, ancestorsIds } = this.props;
  313. if (status && ancestorsIds && ancestorsIds.size > 0) {
  314. const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
  315. window.requestAnimationFrame(() => {
  316. element.scrollIntoView(true);
  317. });
  318. this._scrolledIntoView = true;
  319. }
  320. }
  321. componentWillUnmount () {
  322. detachFullscreenListener(this.onFullScreenChange);
  323. }
  324. onFullScreenChange = () => {
  325. this.setState({ fullscreen: isFullscreen() });
  326. }
  327. render () {
  328. let ancestors, descendants;
  329. const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props;
  330. const { fullscreen } = this.state;
  331. if (status === null) {
  332. return (
  333. <Column>
  334. <ColumnBackButton />
  335. <MissingIndicator />
  336. </Column>
  337. );
  338. }
  339. if (ancestorsIds && ancestorsIds.size > 0) {
  340. ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
  341. }
  342. if (descendantsIds && descendantsIds.size > 0) {
  343. descendants = <div>{this.renderChildren(descendantsIds)}</div>;
  344. }
  345. const handlers = {
  346. moveUp: this.handleHotkeyMoveUp,
  347. moveDown: this.handleHotkeyMoveDown,
  348. reply: this.handleHotkeyReply,
  349. favourite: this.handleHotkeyFavourite,
  350. boost: this.handleHotkeyBoost,
  351. mention: this.handleHotkeyMention,
  352. openProfile: this.handleHotkeyOpenProfile,
  353. toggleHidden: this.handleHotkeyToggleHidden,
  354. };
  355. return (
  356. <Column label={intl.formatMessage(messages.detailedStatus)}>
  357. <ColumnHeader
  358. showBackButton
  359. extraButton={(
  360. <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={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><i className={`fa fa-${status.get('hidden') ? 'eye-slash' : 'eye'}`} /></button>
  361. )}
  362. />
  363. <ScrollContainer scrollKey='thread' shouldUpdateScroll={shouldUpdateScroll}>
  364. <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
  365. {ancestors}
  366. <HotKeys handlers={handlers}>
  367. <div className={classNames('focusable', 'detailed-status__wrapper')} tabIndex='0' aria-label={textForScreenReader(intl, status, false, !status.get('hidden'))}>
  368. <DetailedStatus
  369. status={status}
  370. onOpenVideo={this.handleOpenVideo}
  371. onOpenMedia={this.handleOpenMedia}
  372. onToggleHidden={this.handleToggleHidden}
  373. domain={domain}
  374. />
  375. <ActionBar
  376. status={status}
  377. onReply={this.handleReplyClick}
  378. onFavourite={this.handleFavouriteClick}
  379. onReblog={this.handleReblogClick}
  380. onDelete={this.handleDeleteClick}
  381. onDirect={this.handleDirectClick}
  382. onMention={this.handleMentionClick}
  383. onMute={this.handleMuteClick}
  384. onMuteConversation={this.handleConversationMuteClick}
  385. onBlock={this.handleBlockClick}
  386. onReport={this.handleReport}
  387. onPin={this.handlePin}
  388. onEmbed={this.handleEmbed}
  389. />
  390. </div>
  391. </HotKeys>
  392. {descendants}
  393. </div>
  394. </ScrollContainer>
  395. </Column>
  396. );
  397. }
  398. }