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.

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