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.
 
 
 
 

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