import classNames from 'clsx'; import React, { useState, useRef, useEffect, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; 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 Poll from './polls/poll'; 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(); e.stopPropagation(); history.push(`/@${mention.acct}`); } }; const onHashtagClick = (hashtag: string, e: MouseEvent) => { hashtag = hashtag.replace(/^#/, '').toLowerCase(); if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); e.stopPropagation(); history.push(`/tags/${hashtag}`); } }; /** For regular links, just stop propogation */ const onLinkClick = (e: MouseEvent) => { e.stopPropagation(); }; 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); link.addEventListener('click', onLinkClick.bind(link), false); } }); }; 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); } }; useEffect(() => { maybeSetCollapsed(); maybeSetOnlyEmoji(); updateStatusLinks(); }); 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 parsedHtml = useMemo((): string => { const { contentHtml: html } = status; if (greentext) { return addGreentext(html); } else { return html; } }, [status.contentHtml]); if (status.content.length === 0) { return null; } const isHidden = onExpandedToggle ? !expanded : hidden; const content = { __html: parsedHtml }; 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(); } const hasPoll = status.poll && typeof status.poll === 'string'; if (hasPoll) { output.push(); } return
{output}
; } else { const output = [
, ]; if (status.poll && typeof status.poll === 'string') { output.push(); } return <>{output}; } }; export default React.memo(StatusContent);