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.
 
 
 
 

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