import PropTypes from "prop-types"; import { defineMessages, injectIntl, FormattedMessage } from "react-intl"; import classNames from "classnames"; import { Helmet } from "react-helmet"; import Immutable from "immutable"; import ImmutablePropTypes from "react-immutable-proptypes"; import ImmutablePureComponent from "react-immutable-pure-component"; import { connect } from "react-redux"; import { createSelector } from "reselect"; import { HotKeys } from "react-hotkeys"; import { Icon } from "mastodon/components/icon"; import { LoadingIndicator } from "mastodon/components/loading_indicator"; import ScrollContainer from "mastodon/containers/scroll_container"; import BundleColumnError from "mastodon/features/ui/components/bundle_column_error"; import { unblockAccount, unmuteAccount, } from "../../actions/accounts"; import { initBlockModal } from "../../actions/blocks"; import { initBoostModal } from "../../actions/boosts"; import { replyCompose, mentionCompose, directCompose, } from "../../actions/compose"; import { blockDomain, unblockDomain, } from "../../actions/domain_blocks"; import { favourite, unfavourite, bookmark, unbookmark, reblog, unreblog, pin, unpin, } from "../../actions/interactions"; import { openModal } from "../../actions/modal"; import { initMuteModal } from "../../actions/mutes"; import { initReport } from "../../actions/reports"; import { fetchStatus, muteStatus, unmuteStatus, deleteStatus, editStatus, hideStatus, revealStatus, translateStatus, undoStatusTranslation, } from "../../actions/statuses"; import ColumnHeader from "../../components/column_header"; import { textForScreenReader, defaultMediaVisibility } from "../../components/status"; import StatusContainer from "../../containers/status_container"; import { boostModal, deleteModal } from "../../initial_state"; import { makeGetStatus, makeGetPictureInPicture } from "../../selectors"; import Column from "../ui/components/column"; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from "../ui/util/fullscreen"; import ActionBar from "./components/action_bar"; import DetailedStatus from "./components/detailed_status"; const messages = defineMessages({ deleteConfirm: { id: "confirmations.delete.confirm", defaultMessage: "Delete" }, deleteMessage: { id: "confirmations.delete.message", defaultMessage: "Are you sure you want to delete this status?" }, redraftConfirm: { id: "confirmations.redraft.confirm", defaultMessage: "Delete & redraft" }, redraftMessage: { id: "confirmations.redraft.message", defaultMessage: "Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned." }, revealAll: { id: "status.show_more_all", defaultMessage: "Show more for all" }, hideAll: { id: "status.show_less_all", defaultMessage: "Show less for all" }, statusTitleWithAttachments: { id: "status.title.with_attachments", defaultMessage: "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}" }, detailedStatus: { id: "status.detailed_status", defaultMessage: "Detailed conversation view" }, replyConfirm: { id: "confirmations.reply.confirm", defaultMessage: "Reply" }, replyMessage: { id: "confirmations.reply.message", defaultMessage: "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?" }, blockDomainConfirm: { id: "confirmations.domain_block.confirm", defaultMessage: "Block entire domain" }, }); const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); const getAncestorsIds = createSelector([ (_, { id }) => id, state => state.getIn(["contexts", "inReplyTos"]), ], (statusId, inReplyTos) => { let ancestorsIds = Immutable.List(); ancestorsIds = ancestorsIds.withMutations(mutable => { let id = statusId; while (id && !mutable.includes(id)) { mutable.unshift(id); id = inReplyTos.get(id); } }); return ancestorsIds; }); const getDescendantsIds = createSelector([ (_, { id }) => id, state => state.getIn(["contexts", "replies"]), state => state.get("statuses"), ], (statusId, contextReplies, statuses) => { let descendantsIds = []; const ids = [statusId]; while (ids.length > 0) { let id = ids.pop(); const replies = contextReplies.get(id); if (statusId !== id) { descendantsIds.push(id); } if (replies) { replies.reverse().forEach(reply => { if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) { ids.push(reply); } }); } } let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get("in_reply_to_account_id") !== statuses.get(id).get("account")); if (insertAt !== -1) { descendantsIds.forEach((id, idx) => { if (idx > insertAt && statuses.get(id).get("in_reply_to_account_id") === statuses.get(id).get("account")) { descendantsIds.splice(idx, 1); descendantsIds.splice(insertAt, 0, id); insertAt += 1; } }); } return Immutable.List(descendantsIds); }); const mapStateToProps = (state, props) => { const status = getStatus(state, { id: props.params.statusId }); let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); if (status) { ancestorsIds = getAncestorsIds(state, { id: status.get("in_reply_to_id") }); descendantsIds = getDescendantsIds(state, { id: status.get("id") }); } return { isLoading: state.getIn(["statuses", props.params.statusId, "isLoading"]), status, ancestorsIds, descendantsIds, askReplyConfirmation: state.getIn(["compose", "text"]).trim().length !== 0, domain: state.getIn(["meta", "domain"]), pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }), }; }; return mapStateToProps; }; const truncate = (str, num) => { const arr = Array.from(str); if (arr.length > num) { return arr.slice(0, num).join("") + "…"; } else { return str; } }; const titleFromStatus = (intl, status) => { const displayName = status.getIn(["account", "display_name"]); const username = status.getIn(["account", "username"]); const user = displayName.trim().length === 0 ? username : displayName; const text = status.get("search_index"); const attachmentCount = status.get("media_attachments").size; return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount }); }; class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, identity: PropTypes.object, }; static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, status: ImmutablePropTypes.map, isLoading: PropTypes.bool, ancestorsIds: ImmutablePropTypes.list.isRequired, descendantsIds: ImmutablePropTypes.list.isRequired, intl: PropTypes.object.isRequired, askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, }), }; state = { fullscreen: false, showMedia: defaultMediaVisibility(this.props.status), loadedStatusId: undefined, }; UNSAFE_componentWillMount () { this.props.dispatch(fetchStatus(this.props.params.statusId)); } componentDidMount () { attachFullscreenListener(this.onFullScreenChange); this._scrollStatusIntoView(); } UNSAFE_componentWillReceiveProps (nextProps) { if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { this.props.dispatch(fetchStatus(nextProps.params.statusId)); } if (nextProps.status && nextProps.status.get("id") !== this.state.loadedStatusId) { this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get("id") }); } } handleToggleMediaVisibility = () => { this.setState({ showMedia: !this.state.showMedia }); }; handleFavouriteClick = (status) => { const { dispatch } = this.props; const { signedIn } = this.context.identity; if (signedIn) { if (status.get("favourited")) { dispatch(unfavourite(status)); } else { dispatch(favourite(status)); } } else { dispatch(openModal({ modalType: "INTERACTION", modalProps: { type: "favourite", accountId: status.getIn(["account", "id"]), url: status.get("uri"), }, })); } }; handlePin = (status) => { if (status.get("pinned")) { this.props.dispatch(unpin(status)); } else { this.props.dispatch(pin(status)); } }; handleReplyClick = (status) => { const { askReplyConfirmation, dispatch, intl } = this.props; const { signedIn } = this.context.identity; if (signedIn) { if (askReplyConfirmation) { dispatch(openModal({ modalType: "CONFIRM", modalProps: { message: intl.formatMessage(messages.replyMessage), confirm: intl.formatMessage(messages.replyConfirm), onConfirm: () => dispatch(replyCompose(status, this.context.router.history)), }, })); } else { dispatch(replyCompose(status, this.context.router.history)); } } else { dispatch(openModal({ modalType: "INTERACTION", modalProps: { type: "reply", accountId: status.getIn(["account", "id"]), url: status.get("uri"), }, })); } }; handleModalReblog = (status, privacy) => { this.props.dispatch(reblog(status, privacy)); }; handleReblogClick = (status, e) => { const { dispatch } = this.props; const { signedIn } = this.context.identity; if (signedIn) { if (status.get("reblogged")) { dispatch(unreblog(status)); } else { if ((e && e.shiftKey) || !boostModal) { this.handleModalReblog(status); } else { dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); } } } else { dispatch(openModal({ modalType: "INTERACTION", modalProps: { type: "reblog", accountId: status.getIn(["account", "id"]), url: status.get("uri"), }, })); } }; handleBookmarkClick = (status) => { if (status.get("bookmarked")) { this.props.dispatch(unbookmark(status)); } else { this.props.dispatch(bookmark(status)); } }; handleDeleteClick = (status, history, withRedraft = false) => { const { dispatch, intl } = this.props; if (!deleteModal) { dispatch(deleteStatus(status.get("id"), history, withRedraft)); } else { dispatch(openModal({ modalType: "CONFIRM", modalProps: { message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), onConfirm: () => dispatch(deleteStatus(status.get("id"), history, withRedraft)), }, })); } }; handleEditClick = (status, history) => { this.props.dispatch(editStatus(status.get("id"), history)); }; handleDirectClick = (account, router) => { this.props.dispatch(directCompose(account, router)); }; handleMentionClick = (account, router) => { this.props.dispatch(mentionCompose(account, router)); }; handleOpenMedia = (media, index, lang) => { this.props.dispatch(openModal({ modalType: "MEDIA", modalProps: { statusId: this.props.status.get("id"), media, index, lang }, })); }; handleOpenVideo = (media, lang, options) => { this.props.dispatch(openModal({ modalType: "VIDEO", modalProps: { statusId: this.props.status.get("id"), media, lang, options }, })); }; handleHotkeyOpenMedia = e => { const { status } = this.props; e.preventDefault(); if (status.get("media_attachments").size > 0) { if (status.getIn(["media_attachments", 0, "type"]) === "video") { this.handleOpenVideo(status.getIn(["media_attachments", 0]), { startTime: 0 }); } else { this.handleOpenMedia(status.get("media_attachments"), 0); } } }; handleMuteClick = (account) => { this.props.dispatch(initMuteModal(account)); }; handleConversationMuteClick = (status) => { if (status.get("muted")) { this.props.dispatch(unmuteStatus(status.get("id"))); } else { this.props.dispatch(muteStatus(status.get("id"))); } }; handleToggleHidden = (status) => { if (status.get("hidden")) { this.props.dispatch(revealStatus(status.get("id"))); } else { this.props.dispatch(hideStatus(status.get("id"))); } }; handleToggleAll = () => { const { status, ancestorsIds, descendantsIds } = this.props; const statusIds = [status.get("id")].concat(ancestorsIds.toJS(), descendantsIds.toJS()); if (status.get("hidden")) { this.props.dispatch(revealStatus(statusIds)); } else { this.props.dispatch(hideStatus(statusIds)); } }; handleTranslate = status => { const { dispatch } = this.props; if (status.get("translation")) { dispatch(undoStatusTranslation(status.get("id"), status.get("poll"))); } else { dispatch(translateStatus(status.get("id"))); } }; handleBlockClick = (status) => { const { dispatch } = this.props; const account = status.get("account"); dispatch(initBlockModal(account)); }; handleReport = (status) => { this.props.dispatch(initReport(status.get("account"), status)); }; handleEmbed = (status) => { this.props.dispatch(openModal({ modalType: "EMBED", modalProps: { id: status.get("id") }, })); }; handleUnmuteClick = account => { this.props.dispatch(unmuteAccount(account.get("id"))); }; handleUnblockClick = account => { this.props.dispatch(unblockAccount(account.get("id"))); }; handleBlockDomainClick = domain => { this.props.dispatch(openModal({ modalType: "CONFIRM", modalProps: { message: {domain} }} />, confirm: this.props.intl.formatMessage(messages.blockDomainConfirm), onConfirm: () => this.props.dispatch(blockDomain(domain)), }, })); }; handleUnblockDomainClick = domain => { this.props.dispatch(unblockDomain(domain)); }; handleHotkeyMoveUp = () => { this.handleMoveUp(this.props.status.get("id")); }; handleHotkeyMoveDown = () => { this.handleMoveDown(this.props.status.get("id")); }; handleHotkeyReply = e => { e.preventDefault(); this.handleReplyClick(this.props.status); }; handleHotkeyFavourite = () => { this.handleFavouriteClick(this.props.status); }; handleHotkeyBoost = () => { this.handleReblogClick(this.props.status); }; handleHotkeyMention = e => { e.preventDefault(); this.handleMentionClick(this.props.status.get("account")); }; handleHotkeyOpenProfile = () => { this.context.router.history.push(`/@${this.props.status.getIn(["account", "acct"])}`); }; handleHotkeyToggleHidden = () => { this.handleToggleHidden(this.props.status); }; handleHotkeyToggleSensitive = () => { this.handleToggleMediaVisibility(); }; handleMoveUp = id => { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get("id")) { this._selectChild(ancestorsIds.size - 1, true); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); this._selectChild(ancestorsIds.size + index, true); } else { this._selectChild(index - 1, true); } } }; handleMoveDown = id => { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get("id")) { this._selectChild(ancestorsIds.size + 1, false); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); this._selectChild(ancestorsIds.size + index + 2, false); } else { this._selectChild(index + 1, false); } } }; _selectChild (index, align_top) { const container = this.node; const element = container.querySelectorAll(".focusable")[index]; if (element) { if (align_top && container.scrollTop > element.offsetTop) { element.scrollIntoView(true); } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { element.scrollIntoView(false); } element.focus(); } } renderChildren (list, ancestors) { const { params: { statusId } } = this.props; return list.map((id, i) => ( 0 ? list.get(i - 1) : undefined} nextId={list.get(i + 1) || (ancestors && statusId)} rootId={statusId} /> )); } setRef = c => { this.node = c; }; _scrollStatusIntoView () { const { status, multiColumn } = this.props; if (status) { window.requestAnimationFrame(() => { this.node?.querySelector(".detailed-status__wrapper")?.scrollIntoView(true); // In the single-column interface, `scrollIntoView` will put the post behind the header, // so compensate for that. if (!multiColumn) { const offset = document.querySelector(".column-header__wrapper")?.getBoundingClientRect()?.bottom; if (offset) { const scrollingElement = document.scrollingElement || document.body; scrollingElement.scrollBy(0, -offset); } } }); } } componentDidUpdate (prevProps) { const { status, ancestorsIds } = this.props; if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get("id") !== status.get("id"))) { this._scrollStatusIntoView(); } } componentWillUnmount () { detachFullscreenListener(this.onFullScreenChange); } onFullScreenChange = () => { this.setState({ fullscreen: isFullscreen() }); }; shouldUpdateScroll = (prevRouterProps, { location }) => { // Do not change scroll when opening a modal if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { return false; } // Scroll to focused post if it is loaded const child = this.node?.querySelector(".detailed-status__wrapper"); if (child) { return [0, child.offsetTop]; } // Do not scroll otherwise, `componentDidUpdate` will take care of that return false; }; render () { let ancestors, descendants; const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; const { fullscreen } = this.state; if (isLoading) { return ( ); } if (status === null) { return ( ); } if (ancestorsIds && ancestorsIds.size > 0) { ancestors = <>{this.renderChildren(ancestorsIds, true)}; } if (descendantsIds && descendantsIds.size > 0) { descendants = <>{this.renderChildren(descendantsIds)}; } const isLocal = status.getIn(["account", "acct"], "").indexOf("@") === -1; const isIndexable = !status.getIn(["account", "noindex"]); const handlers = { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, mention: this.handleHotkeyMention, openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, openMedia: this.handleHotkeyOpenMedia, }; return ( )} />
{ancestors}
{descendants}
{titleFromStatus(intl, status)}
); } } export default injectIntl(connect(makeMapStateToProps)(Status));