From cfbba43ca42f6b97a1db4ba0ff1dc0537cce283f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 15 Apr 2022 16:59:42 -0500 Subject: [PATCH 1/3] StatusContent: typescript, React.FC --- app/soapbox/components/status_content.js | 297 ---------------------- app/soapbox/components/status_content.tsx | 288 +++++++++++++++++++++ 2 files changed, 288 insertions(+), 297 deletions(-) delete mode 100644 app/soapbox/components/status_content.js create mode 100644 app/soapbox/components/status_content.tsx diff --git a/app/soapbox/components/status_content.js b/app/soapbox/components/status_content.js deleted file mode 100644 index 663480a75..000000000 --- a/app/soapbox/components/status_content.js +++ /dev/null @@ -1,297 +0,0 @@ -import classnames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import Icon from 'soapbox/components/icon'; -import Poll from 'soapbox/components/poll'; -import { addGreentext } from 'soapbox/utils/greentext'; -import { onlyEmoji } from 'soapbox/utils/rich_content'; - -import { isRtl } from '../rtl'; - -import Permalink from './permalink'; - -const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) -const BIG_EMOJI_LIMIT = 10; - -const mapStateToProps = state => ({ - 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..6459f93e1 --- /dev/null +++ b/app/soapbox/components/status_content.tsx @@ -0,0 +1,288 @@ +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 Permalink from './permalink'; + +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 IStatusContent { + status: Status, + reblogContent?: string, + expanded?: boolean, + onExpandedToggle?: () => void, + onClick?: () => void, + collapsable?: boolean, +} + +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'); + + 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 = status.mentions.find(mention => link.href === `${mention.url}`); + + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[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(); + + if (onExpandedToggle) { + // The parent manages the state + onExpandedToggle(); + } else { + setHidden(!hidden); + } + }; + + // const handleCollapsedClick: React.EventHandler = (e) => { + // e.preventDefault(); + // setCollapsed(!collapsed); + // }; + + 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'; + } + + const readMoreButton = ( + + ); + + if (status.spoiler_text.length > 0) { + // let mentionsPlaceholder: React.ReactNode = ''; + // + // const mentionLinks = status.mentions.map(mention => ( + // + // @{mention.username} + // + // )).reduce((aggregate, mention) => [...aggregate, mention, ' '], []); + // + // if (isHidden) { + // mentionsPlaceholder =
{mentionLinks}
; + // } + + return ( +
+

+ + + +

+ + {/* mentionsPlaceholder */} + +
+ + {!isHidden && status.poll && typeof status.poll === 'string' && ( + + )} +
+ ); + } else if (onClick) { + const output = [ +
, + ]; + + if (collapsed) { + output.push(readMoreButton); + } + + 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; From 2cfb151542dd7a47b1636cd54f1ff5988ae01a7e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 15 Apr 2022 17:41:53 -0500 Subject: [PATCH 2/3] StatusContent: break out ReadMoreButton and SpoilerButton into FC's --- app/soapbox/components/status_content.tsx | 85 +++++++++++++---------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx index 6459f93e1..00b9ef30f 100644 --- a/app/soapbox/components/status_content.tsx +++ b/app/soapbox/components/status_content.tsx @@ -11,8 +11,6 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content'; import { isRtl } from '../rtl'; -// import Permalink from './permalink'; - import type { Status, Mention } from 'soapbox/types/entities'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) @@ -23,15 +21,58 @@ type Point = [ 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, - reblogContent?: string, + reblogContent?: string, // FIXME: not used! 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(); @@ -141,6 +182,7 @@ const StatusContent: React.FC = ({ status, expanded = false, onE const handleSpoilerClick: React.EventHandler = (e) => { e.preventDefault(); + e.stopPropagation(); if (onExpandedToggle) { // The parent manages the state @@ -181,48 +223,19 @@ const StatusContent: React.FC = ({ status, expanded = false, onE directionStyle.direction = 'rtl'; } - const readMoreButton = ( - - ); - if (status.spoiler_text.length > 0) { - // let mentionsPlaceholder: React.ReactNode = ''; - // - // const mentionLinks = status.mentions.map(mention => ( - // - // @{mention.username} - // - // )).reduce((aggregate, mention) => [...aggregate, mention, ' '], []); - // - // if (isHidden) { - // mentionsPlaceholder =
{mentionLinks}
; - // } - return (

- + hidden={isHidden} + />

- {/* mentionsPlaceholder */} -
= ({ status, expanded = false, onE ]; if (collapsed) { - output.push(readMoreButton); + output.push(); } if (status.poll && typeof status.poll === 'string') { From be9bfa6409da4eacc1242759f84151a131e9517c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 15 Apr 2022 17:51:12 -0500 Subject: [PATCH 3/3] StatusContent: refactor updateStatusLinks, remove reblogContent prop --- app/soapbox/components/status.tsx | 4 +--- app/soapbox/components/status_content.tsx | 21 ++++++++------------- 2 files changed, 9 insertions(+), 16 deletions(-) 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 { = ({ onClick, hidden, tabIndex }) interface IStatusContent { status: Status, - reblogContent?: string, // FIXME: not used! expanded?: boolean, onExpandedToggle?: () => void, onClick?: () => void, @@ -106,26 +105,27 @@ const StatusContent: React.FC = ({ status, expanded = false, onE const links = node.current.querySelectorAll('a'); - for (let i = 0; i < links.length; ++i) { - const link = links[i]; - if (link.classList.contains('status-link')) { - continue; - } + 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 && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + } 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 => { @@ -192,11 +192,6 @@ const StatusContent: React.FC = ({ status, expanded = false, onE } }; - // const handleCollapsedClick: React.EventHandler = (e) => { - // e.preventDefault(); - // setCollapsed(!collapsed); - // }; - const getHtmlContent = (): string => { const { contentHtml: html } = status; if (greentext) return addGreentext(html);