diff --git a/app/soapbox/components/status_content.js b/app/soapbox/components/status_content.js deleted file mode 100644 index 663480a75e..0000000000 Binary files a/app/soapbox/components/status_content.js and /dev/null differ diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx new file mode 100644 index 0000000000..6459f93e1e --- /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;