diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 25c95fb9e..24521a435 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -19,9 +19,8 @@ import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import { displayMedia } from '../initial_state'; -import StatusContainer from '../containers/status_container'; -import { makeGetStatus } from '../selectors'; -import { createSelector } from 'reselect'; +import StatusContainer from '../containers/status_container2'; +//import DetailedStatus from '../features/status/components/detailed_status'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -56,40 +55,7 @@ export const defaultMediaVisibility = (status) => { return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); }; -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const getSons = createSelector([ - (_, { id }) => id, - state => state.getIn(['contexts', 'replies']), - state => state.get('statuses'), - ], (statusId, contextReplies, statuses) => { - - return Immutable.List(contextReplies.get(statusId)); - }); - - const mapStateToProps = (state, props) => { - const status = getStatus(state, { id: props.params.statusId }); - let noFather = false; - let sonsIds = Immutable.List(); - - if (status) { - noFather = (status.get('in_reply_to_id') == null); - sonsIds = getSons(state, { id: status.get('id') }); - } - - return { - status, - noFather, - sonsIds, - }; - }; - - return mapStateToProps; -}; - export default @injectIntl -@connect(makeMapStateToProps) class Status extends ImmutablePureComponent { static contextTypes = { @@ -124,9 +90,8 @@ class Status extends ImmutablePureComponent { updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, - - noFather: ImmutablePropTypes.bool, - sonsIds: ImmutablePropTypes.list, + + sonsIds: ImmutablePropTypes.list, }; // Avoid checking props that are functions (and whose equality will always @@ -312,23 +277,39 @@ class Status extends ImmutablePureComponent { } renderChildren (list) { + /* + return list.map(id => ( + + + )); + */ return list.map(id => ( {}} + onMoveDown={()=>{}} contextType='thread' /> )); + } - + render () { let media = null; let statusAvatar, prepend, rebloggedByText; - let sons; + let sons; - const { intl, hidden, featured, otherAccounts, unread, showThread, noFather, sonsIds } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, sonsIds } = this.props; let { status, account, ...other } = this.props; @@ -483,9 +464,9 @@ class Status extends ImmutablePureComponent { statusAvatar = ; } - if(noFather && sonsIds && sonsIds.size > 0) { - sons =
{this.renderChildren(sonsIds)}
; - } + if(status.get('in_reply_to_id') == null && sonsIds && sonsIds.size > 0) { + sons =
{this.renderChildren(sonsIds)}
; + } return ( @@ -519,7 +500,7 @@ class Status extends ImmutablePureComponent { - {sons} + {sons} ); } diff --git a/app/javascript/mastodon/components/status2.js b/app/javascript/mastodon/components/status2.js new file mode 100644 index 000000000..f6814d8a0 --- /dev/null +++ b/app/javascript/mastodon/components/status2.js @@ -0,0 +1,473 @@ +import React from 'react'; +import Immutable from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import AvatarOverlay from './avatar_overlay'; +import AvatarComposite from './avatar_composite'; +import RelativeTimestamp from './relative_timestamp'; +import DisplayName from './display_name'; +import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; +import AttachmentList from './attachment_list'; +import Card from '../features/status/components/card'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; +import { displayMedia } from '../initial_state'; + +//import DetailedStatus from '../features/status/components/detailed_status'; + +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; + +export const textForScreenReader = (intl, status, rebloggedByText = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), + intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + status.getIn(['account', 'acct']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +export const defaultMediaVisibility = (status) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +}; + +export default @injectIntl +class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + account: ImmutablePropTypes.map, + otherAccounts: ImmutablePropTypes.list, + onClick: PropTypes.func, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onPin: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onBlock: PropTypes.func, + onEmbed: PropTypes.func, + onHeightChange: PropTypes.func, + onToggleHidden: PropTypes.func, + muted: PropTypes.bool, + hidden: PropTypes.bool, + unread: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + showThread: PropTypes.bool, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + + sonsIds: ImmutablePropTypes.list, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'muted', + 'hidden', + ]; + + state = { + showMedia: defaultMediaVisibility(this.props.status), + statusId: undefined, + }; + + // Track height changes we know about to compensate scrolling + componentDidMount () { + this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + } + + getSnapshotBeforeUpdate () { + if (this.props.getScrollPosition) { + return this.props.getScrollPosition(); + } else { + return null; + } + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + return { + showMedia: defaultMediaVisibility(nextProps.status), + statusId: nextProps.status.get('id'), + }; + } else { + return null; + } + } + + // Compensate height changes + componentDidUpdate (prevProps, prevState, snapshot) { + const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + + if (doShowCard && !this.didShowCard) { + this.didShowCard = true; + + if (snapshot !== null && this.props.updateScrollBottom) { + if (this.node && this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); + } + } + } + } + + componentWillUnmount() { + if (this.node && this.props.getScrollPosition) { + const position = this.props.getScrollPosition(); + if (position !== null && this.node.offsetTop < position.top) { + requestAnimationFrame(() => { + this.props.updateScrollBottom(position.height - position.top); + }); + } + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + + handleClick = () => { + if (this.props.onClick) { + this.props.onClick(); + return; + } + + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + } + + handleExpandClick = (e) => { + if (this.props.onClick) { + this.props.onClick(); + return; + } + + if (e.button === 0) { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + } + } + + handleAccountClick = (e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + const id = e.currentTarget.getAttribute('data-id'); + e.preventDefault(); + this.context.router.history.push(`/accounts/${id}`); + } + } + + handleExpandedToggle = () => { + this.props.onToggleHidden(this._properStatus()); + }; + + renderLoadingMediaGallery () { + return
; + } + + renderLoadingVideoPlayer () { + return
; + } + + renderLoadingAudioPlayer () { + return
; + } + + handleOpenVideo = (media, startTime) => { + this.props.onOpenVideo(media, startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this._properStatus(), this.context.router.history); + } + + handleHotkeyFavourite = () => { + this.props.onFavourite(this._properStatus()); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this._properStatus(), e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this._properStatus().get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); + } + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + } + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + } + + handleHotkeyToggleHidden = () => { + this.props.onToggleHidden(this._properStatus()); + } + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } + } + + handleRef = c => { + this.node = c; + } + + render () { + let media = null; + let statusAvatar, prepend, rebloggedByText; + + const { intl, hidden, featured, otherAccounts, unread, showThread, sonsIds } = this.props; + + let { status, account, ...other } = this.props; + + if (status === null) { + return null; + } + + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + }; + + if (hidden) { + return ( + +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} +
+
+ ); + } + + if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const minHandlers = this.props.muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + +
+ +
+
+ ); + } + + if (featured) { + prepend = ( +
+
+ +
+ ); + } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; + + prepend = ( +
+
+ }} /> +
+ ); + + rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) }); + + account = status.get('account'); + status = status.get('reblog'); + } + + if (status.get('media_attachments').size > 0) { + if (this.props.muted) { + media = ( + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else { + media = ( + + {Component => ( + + )} + + ); + } + } else if (status.get('spoiler_text').length === 0 && status.get('card')) { + media = ( + + ); + } + + if (otherAccounts && otherAccounts.size > 0) { + statusAvatar = ; + } else if (account === undefined || account === null) { + statusAvatar = ; + } else { + statusAvatar = ; + } + + return ( + +
+ {prepend} + +
+
+ + + + + {media} + + {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( + + )} + + +
+
+ + ); + } + +} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index e1b370c91..9b9db6b78 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -100,6 +100,7 @@ export default class StatusList extends ImmutablePureComponent { onMoveDown={this.handleMoveDown} contextType={timelineId} showThread + com_prev /> )) ) : null; diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index fb22676e0..a15ece512 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -43,6 +43,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), + sonsIds: 'com_prev' in props ? state.getIn(['contexts', 'replies', props.id]) : null, }); return mapStateToProps; diff --git a/app/javascript/mastodon/containers/status_container2.js b/app/javascript/mastodon/containers/status_container2.js new file mode 100644 index 000000000..6da2b0b82 --- /dev/null +++ b/app/javascript/mastodon/containers/status_container2.js @@ -0,0 +1,168 @@ +import { connect } from 'react-redux'; +import Status from '../components/status2'; +import { makeGetStatus } from '../selectors'; +import { + replyCompose, + mentionCompose, + directCompose, +} from '../actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, + pin, + unpin, +} from '../actions/interactions'; +import { + muteStatus, + unmuteStatus, + deleteStatus, + hideStatus, + revealStatus, +} from '../actions/statuses'; +import { initMuteModal } from '../actions/mutes'; +import { initBlockModal } from '../actions/blocks'; +import { initReport } from '../actions/reports'; +import { openModal } from '../actions/modal'; +import { defineMessages, injectIntl } from 'react-intl'; +import { boostModal, deleteModal } from '../initial_state'; +import { showAlertForError } from '../actions/alerts'; + +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? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, + 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?' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + sonsIds: state.getIn(['contexts', 'replies', props.id]), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status, router)), + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + onModalReblog (status) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status)); + } + }, + + onReblog (status, e) { + if ((e && e.shiftKey) || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + }, + + onFavourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { + url: status.get('url'), + onError: error => dispatch(showAlertForError(error)), + })); + }, + + onDelete (status, history, withRedraft = false) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), + })); + } + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (status) { + const account = status.get('account'); + dispatch(initBlockModal(account)); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(initMuteModal(account)); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + + onToggleHidden (status) { + if (status.get('hidden')) { + dispatch(revealStatus(status.get('id'))); + } else { + dispatch(hideStatus(status.get('id'))); + } + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));