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.
 
 
 
 

452 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, { id: 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. contextType='thread'
  285. />
  286. ));
  287. }
  288. setRef = c => {
  289. this.node = c;
  290. }
  291. componentDidUpdate () {
  292. if (this._scrolledIntoView) {
  293. return;
  294. }
  295. const { status, ancestorsIds } = this.props;
  296. if (status && ancestorsIds && ancestorsIds.size > 0) {
  297. const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
  298. element.scrollIntoView(true);
  299. this._scrolledIntoView = true;
  300. }
  301. }
  302. componentWillUnmount () {
  303. detachFullscreenListener(this.onFullScreenChange);
  304. }
  305. onFullScreenChange = () => {
  306. this.setState({ fullscreen: isFullscreen() });
  307. }
  308. render () {
  309. let ancestors, descendants;
  310. const { status, ancestorsIds, descendantsIds, intl } = this.props;
  311. const { fullscreen } = this.state;
  312. if (status === null) {
  313. return (
  314. <Column>
  315. <ColumnBackButton />
  316. <MissingIndicator />
  317. </Column>
  318. );
  319. }
  320. if (ancestorsIds && ancestorsIds.size > 0) {
  321. ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
  322. }
  323. if (descendantsIds && descendantsIds.size > 0) {
  324. descendants = <div>{this.renderChildren(descendantsIds)}</div>;
  325. }
  326. const handlers = {
  327. moveUp: this.handleHotkeyMoveUp,
  328. moveDown: this.handleHotkeyMoveDown,
  329. reply: this.handleHotkeyReply,
  330. favourite: this.handleHotkeyFavourite,
  331. boost: this.handleHotkeyBoost,
  332. mention: this.handleHotkeyMention,
  333. openProfile: this.handleHotkeyOpenProfile,
  334. toggleHidden: this.handleHotkeyToggleHidden,
  335. };
  336. return (
  337. <Column>
  338. <ColumnHeader
  339. showBackButton
  340. extraButton={(
  341. <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>
  342. )}
  343. />
  344. <ScrollContainer scrollKey='thread'>
  345. <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
  346. {ancestors}
  347. <HotKeys handlers={handlers}>
  348. <div className='focusable' tabIndex='0'>
  349. <DetailedStatus
  350. status={status}
  351. onOpenVideo={this.handleOpenVideo}
  352. onOpenMedia={this.handleOpenMedia}
  353. onToggleHidden={this.handleToggleHidden}
  354. />
  355. <ActionBar
  356. status={status}
  357. onReply={this.handleReplyClick}
  358. onFavourite={this.handleFavouriteClick}
  359. onReblog={this.handleReblogClick}
  360. onDelete={this.handleDeleteClick}
  361. onDirect={this.handleDirectClick}
  362. onMention={this.handleMentionClick}
  363. onMute={this.handleMuteClick}
  364. onMuteConversation={this.handleConversationMuteClick}
  365. onBlock={this.handleBlockClick}
  366. onReport={this.handleReport}
  367. onPin={this.handlePin}
  368. onEmbed={this.handleEmbed}
  369. />
  370. </div>
  371. </HotKeys>
  372. {descendants}
  373. </div>
  374. </ScrollContainer>
  375. </Column>
  376. );
  377. }
  378. }