diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 812f8d377..c3417c35e 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -342,7 +342,7 @@ class Status extends ImmutablePureComponent { render() { let media = null; const poll = null; - let prepend, rebloggedByText, reblogContent, reblogElement, reblogElementMobile; + let prepend, rebloggedByText, reblogElement, reblogElementMobile; const { intl, hidden, featured, unread, group } = this.props; @@ -447,7 +447,6 @@ class Status extends ImmutablePureComponent { // @ts-ignore what the FUCK account = status.account; - reblogContent = status.contentHtml; status = status.reblog; } @@ -646,7 +645,6 @@ class Status extends ImmutablePureComponent { ({ - greentext: getSoapboxConfig(state).get('greentext'), -}); - -export default @connect(mapStateToProps) -@withRouter -class StatusContent extends React.PureComponent { - - static propTypes = { - status: ImmutablePropTypes.record.isRequired, - reblogContent: PropTypes.string, - expanded: PropTypes.bool, - onExpandedToggle: PropTypes.func, - onClick: PropTypes.func, - collapsable: PropTypes.bool, - greentext: PropTypes.bool, - history: PropTypes.object, - }; - - state = { - hidden: true, - collapsed: null, // `collapsed: null` indicates that an element doesn't need collapsing, while `true` or `false` indicates that it does (and is/isn't). - }; - - _updateStatusLinks() { - const node = this.node; - - if (!node) { - return; - } - - const links = node.querySelectorAll('a'); - - for (let i = 0; i < links.length; ++i) { - const link = links[i]; - if (link.classList.contains('status-link')) { - continue; - } - link.classList.add('status-link'); - link.setAttribute('rel', 'nofollow noopener'); - link.setAttribute('target', '_blank'); - - const 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')); - } 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); - } else { - link.setAttribute('title', link.href); - } - } - } - - setCollapse() { - const node = this.node; - - if (!node) { - return; - } - - if ( - this.props.collapsable - && this.props.onClick - && this.state.collapsed === null - && this.props.status.get('spoiler_text').length === 0 - ) { - if (node.clientHeight > MAX_HEIGHT) { - this.setState({ collapsed: true }); - } - } - } - - setOnlyEmoji = () => { - if (!this.node) return; - const only = onlyEmoji(this.node, BIG_EMOJI_LIMIT, true); - - if (only !== this.state.onlyEmoji) { - this.setState({ onlyEmoji: only }); - } - } - - refresh = () => { - this.setCollapse(); - this._updateStatusLinks(); - this.setOnlyEmoji(); - } - - componentDidMount() { - this.refresh(); - } - - componentDidUpdate() { - this.refresh(); - } - - onMentionClick = (mention, e) => { - if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.props.history.push(`/@${mention.get('acct')}`); - } - } - - onHashtagClick = (hashtag, e) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); - - if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.props.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)]; - - if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { - return; - } - - 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 }); - } - } - - handleCollapsedClick = (e) => { - e.preventDefault(); - this.setState({ collapsed: !this.state.collapsed }); - } - - setRef = (c) => { - this.node = c; - } - - parseHtml = html => { - const { greentext } = this.props; - if (greentext) return addGreentext(html); - return html; - } - - getHtmlContent = () => { - const { status } = this.props; - const html = status.get('contentHtml'); - return this.parseHtml(html); - } - - render() { - const { status } = this.props; - const { onlyEmoji } = this.state; - - if (status.get('content').length === 0) { - return null; - } - - const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; - - const content = { __html: this.getHtmlContent() }; - const spoilerContent = { __html: status.get('spoilerHtml') }; - const directionStyle = { direction: 'ltr' }; - const classNames = classnames('status__content', { - 'status__content--with-action': this.props.onClick && this.props.history, - 'status__content--with-spoiler': status.get('spoiler_text').length > 0, - 'status__content--collapsed': this.state.collapsed === true, - 'status__content--big': onlyEmoji, - }); - - if (isRtl(status.get('search_index'))) { - directionStyle.direction = 'rtl'; - } - - const readMoreButton = ( - - ); - - if (status.get('spoiler_text').length > 0) { - let mentionsPlaceholder = ''; - - const mentionLinks = status.get('mentions').map(item => ( - - @{item.get('username')} - - )).reduce((aggregate, item) => [...aggregate, item, ' '], []); - - const toggleText = hidden ? : ; - - if (hidden) { - mentionsPlaceholder =
{mentionLinks}
; - } - - return ( -
- - - {mentionsPlaceholder} - -
- - {!hidden && !!status.get('poll') && } -
- ); - } else if (this.props.onClick) { - const output = [ -
, - ]; - - if (this.state.collapsed) { - output.push(readMoreButton); - } - - if (status.get('poll')) { - output.push(); - } - - return output; - } else { - const output = [ -
, - ]; - - if (status.get('poll')) { - output.push(); - } - - return output; - } - } - -} diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx new file mode 100644 index 000000000..ef53d55a0 --- /dev/null +++ b/app/soapbox/components/status_content.tsx @@ -0,0 +1,296 @@ +import classNames from 'classnames'; +import React, { useState, useRef, useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import Icon from 'soapbox/components/icon'; +import Poll from 'soapbox/components/poll'; +import { useSoapboxConfig } from 'soapbox/hooks'; +import { addGreentext } from 'soapbox/utils/greentext'; +import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content'; + +import { isRtl } from '../rtl'; + +import type { Status, Mention } from 'soapbox/types/entities'; + +const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) +const BIG_EMOJI_LIMIT = 10; + +type Point = [ + x: number, + y: number, +] + +interface IReadMoreButton { + onClick: React.MouseEventHandler, +} + +/** Button to expand a truncated status (due to too much content) */ +const ReadMoreButton: React.FC = ({ onClick }) => ( + +); + +interface ISpoilerButton { + onClick: React.MouseEventHandler, + hidden: boolean, + tabIndex?: number, +} + +/** Button to expand status text behind a content warning */ +const SpoilerButton: React.FC = ({ onClick, hidden, tabIndex }) => ( + +); + +interface IStatusContent { + status: Status, + expanded?: boolean, + onExpandedToggle?: () => void, + onClick?: () => void, + collapsable?: boolean, +} + +/** Renders the text content of a status */ +const StatusContent: React.FC = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false }) => { + const history = useHistory(); + + const [hidden, setHidden] = useState(true); + const [collapsed, setCollapsed] = useState(false); + const [onlyEmoji, setOnlyEmoji] = useState(false); + + const startXY = useRef(); + const node = useRef(null); + + const { greentext } = useSoapboxConfig(); + + const onMentionClick = (mention: Mention, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(`/tags/${hashtag}`); + } + }; + + const updateStatusLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = status.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions and hashtags + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + link.setAttribute('title', link.href); + } + }); + }; + + const maybeSetCollapsed = (): void => { + if (!node.current) return; + + if (collapsable && onClick && !collapsed && status.spoiler_text.length === 0) { + if (node.current.clientHeight > MAX_HEIGHT) { + setCollapsed(true); + } + } + }; + + const maybeSetOnlyEmoji = (): void => { + if (!node.current) return; + const only = isOnlyEmoji(node.current, BIG_EMOJI_LIMIT, true); + + if (only !== onlyEmoji) { + setOnlyEmoji(only); + } + }; + + const refresh = (): void => { + maybeSetCollapsed(); + maybeSetOnlyEmoji(); + updateStatusLinks(); + }; + + useEffect(() => { + refresh(); + }); + + const handleMouseDown: React.EventHandler = (e) => { + startXY.current = [e.clientX, e.clientY]; + }; + + const handleMouseUp: React.EventHandler = (e) => { + if (!startXY.current) return; + const target = e.target as HTMLElement; + const parentNode = target.parentNode as HTMLElement; + + const [ startX, startY ] = startXY.current; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0 && onClick) { + onClick(); + } + + startXY.current = undefined; + }; + + const handleSpoilerClick: React.EventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (onExpandedToggle) { + // The parent manages the state + onExpandedToggle(); + } else { + setHidden(!hidden); + } + }; + + const getHtmlContent = (): string => { + const { contentHtml: html } = status; + if (greentext) return addGreentext(html); + return html; + }; + + if (status.content.length === 0) { + return null; + } + + const isHidden = onExpandedToggle ? !expanded : hidden; + + const content = { __html: getHtmlContent() }; + const spoilerContent = { __html: status.spoilerHtml }; + const directionStyle: React.CSSProperties = { direction: 'ltr' }; + const className = classNames('status__content', { + 'status__content--with-action': onClick, + 'status__content--with-spoiler': status.spoiler_text.length > 0, + 'status__content--collapsed': collapsed, + 'status__content--big': onlyEmoji, + }); + + if (isRtl(status.search_index)) { + directionStyle.direction = 'rtl'; + } + + if (status.spoiler_text.length > 0) { + return ( +
+

+ + +

+ +
+ + {!isHidden && status.poll && typeof status.poll === 'string' && ( + + )} +
+ ); + } else if (onClick) { + const output = [ +
, + ]; + + if (collapsed) { + output.push(); + } + + if (status.poll && typeof status.poll === 'string') { + output.push(); + } + + return <>{output}; + } else { + const output = [ +
, + ]; + + if (status.poll && typeof status.poll === 'string') { + output.push(); + } + + return <>{output}; + } +}; + +export default StatusContent;