diff options
Diffstat (limited to 'app/javascript/mastodon/components/status_content.jsx')
-rw-r--r-- | app/javascript/mastodon/components/status_content.jsx | 304 |
1 files changed, 304 insertions, 0 deletions
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx new file mode 100644 index 000000000..ece54621f --- /dev/null +++ b/app/javascript/mastodon/components/status_content.jsx @@ -0,0 +1,304 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import classnames from 'classnames'; +import PollContainer from 'mastodon/containers/poll_container'; +import Icon from 'mastodon/components/icon'; +import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state'; + +const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) + +class TranslateButton extends React.PureComponent { + + static propTypes = { + translation: ImmutablePropTypes.map, + onClick: PropTypes.func, + }; + + render () { + const { translation, onClick } = this.props; + + if (translation) { + const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language')); + const languageName = language ? language[2] : translation.get('detected_source_language'); + const provider = translation.get('provider'); + + return ( + <div className='translate-button'> + <div className='translate-button__meta'> + <FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} /> + </div> + + <button className='link-button' onClick={onClick}> + <FormattedMessage id='status.show_original' defaultMessage='Show original' /> + </button> + </div> + ); + } + + return ( + <button className='status__content__read-more-button' onClick={onClick}> + <FormattedMessage id='status.translate' defaultMessage='Translate' /> + </button> + ); + } + +} + +export default @injectIntl +class StatusContent extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + identity: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.bool, + onExpandedToggle: PropTypes.func, + onTranslate: PropTypes.func, + onClick: PropTypes.func, + collapsable: PropTypes.bool, + onCollapsedToggle: PropTypes.func, + intl: PropTypes.object, + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.node; + + if (!node) { + return; + } + + const { status, onCollapsedToggle } = this.props; + const links = node.querySelectorAll('a'); + + let link, mention; + + for (var i = 0; i < links.length; ++i) { + link = links[i]; + + if (link.classList.contains('status-link')) { + continue; + } + + link.classList.add('status-link'); + + mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + link.setAttribute('href', `/@${mention.get('acct')}`); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + link.setAttribute('href', `/tags/${link.text.slice(1)}`); + } else { + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + } + + if (status.get('collapsed', null) === null && onCollapsedToggle) { + const { collapsable, onClick } = this.props; + + const collapsed = + collapsable + && onClick + && node.clientHeight > MAX_HEIGHT + && status.get('spoiler_text').length === 0; + + onCollapsedToggle(collapsed); + } + } + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + }; + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + }; + + componentDidMount () { + this._updateStatusLinks(); + } + + componentDidUpdate () { + this._updateStatusLinks(); + } + + onMentionClick = (mention, e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/@${mention.get('acct')}`); + } + }; + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/tags/${hashtag}`); + } + }; + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + }; + + handleMouseUp = (e) => { + if (!this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + let element = e.target; + while (element) { + if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') { + return; + } + element = element.parentNode; + } + + if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { + this.props.onClick(); + } + + this.startXY = null; + }; + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.onExpandedToggle) { + // The parent manages the state + this.props.onExpandedToggle(); + } else { + this.setState({ hidden: !this.state.hidden }); + } + }; + + handleTranslate = () => { + this.props.onTranslate(); + }; + + setRef = (c) => { + this.node = c; + }; + + render () { + const { status, intl } = this.props; + + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + const renderReadMore = this.props.onClick && status.get('collapsed'); + const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language'); + + const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') }; + const spoilerContent = { __html: status.get('spoilerHtml') }; + const lang = status.get('translation') ? intl.locale : status.get('language'); + const classNames = classnames('status__content', { + 'status__content--with-action': this.props.onClick && this.context.router, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + 'status__content--collapsed': renderReadMore, + }); + + const readMoreButton = renderReadMore && ( + <button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'> + <FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth /> + </button> + ); + + const translateButton = renderTranslate && ( + <TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} /> + ); + + const poll = !!status.get('poll') && ( + <PollContainer pollId={status.get('poll')} /> + ); + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Link to={`/@${item.get('acct')}`} key={item.get('id')} className='status-link mention'> + @<span>{item.get('username')}</span> + </Link> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> + <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} /> + {' '} + <button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button> + </p> + + {mentionsPlaceholder} + + <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} /> + + {!hidden && poll} + {!hidden && translateButton} + </div> + ); + } else if (this.props.onClick) { + return ( + <> + <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> + + {poll} + {translateButton} + </div> + + {readMoreButton} + </> + ); + } else { + return ( + <div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> + + {poll} + {translateButton} + </div> + ); + } + } + +} |