import clsx from 'clsx'; import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser'; import React, { useState, useRef, useLayoutEffect, useMemo } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { toggleStatusSpoilerExpanded } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; import { Button, Text } from 'soapbox/components/ui'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; import { getTextDirection } from '../utils/rtl'; import HashtagLink from './hashtag-link'; import HoverRefWrapper from './hover-ref-wrapper'; import Markup from './markup'; import Poll from './polls/poll'; import type { Sizes } from 'soapbox/components/ui/text/text'; import type { MinifiedStatus } from 'soapbox/reducers/statuses'; const MAX_HEIGHT = 322; // 20px * 16 (+ 2px padding at the top) const BIG_EMOJI_LIMIT = 10; const messages = defineMessages({ collapse: { id: 'status.spoiler.collapse', defaultMessage: 'Collapse' }, expand: { id: 'status.spoiler.expand', defaultMessage: 'Expand' }, }); interface IReadMoreButton { onClick: React.MouseEventHandler; } /** Button to expand a truncated status (due to too much content) */ const ReadMoreButton: React.FC = ({ onClick }) => (
); interface IStatusContent { status: MinifiedStatus; onClick?: () => void; collapsable?: boolean; translatable?: boolean; textSize?: Sizes; } /** Renders the text content of a status */ const StatusContent: React.FC = React.memo(({ status, onClick, collapsable = false, translatable, textSize = 'md', }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { displaySpoilers } = useSettings(); const [collapsed, setCollapsed] = useState(false); const [onlyEmoji, setOnlyEmoji] = useState(false); const node = useRef(null); const maybeSetCollapsed = (): void => { if (!node.current) return; if (collapsable && onClick && !collapsed) { 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 toggleExpanded: React.MouseEventHandler = (e) => { e.preventDefault(); e.stopPropagation(); dispatch(toggleStatusSpoilerExpanded(status)); }; useLayoutEffect(() => { maybeSetCollapsed(); maybeSetOnlyEmoji(); }); const parsedHtml = useMemo( (): string => translatable && status.translation ? status.translation.content! : (status.contentMapHtml && status.currentLanguage) ? (status.contentMapHtml[status.currentLanguage] || status.contentHtml) : status.contentHtml, [status.contentHtml, status.translation, status.currentLanguage], ); if (status.content.length === 0) { return null; } const withSpoiler = status.spoiler_text.length > 0; const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const options: HTMLReactParserOptions = { replace(domNode) { if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) { return null; } if (domNode instanceof Element && domNode.name === 'a') { const classes = domNode.attribs.class?.split(' '); if (classes?.includes('mention')) { const mention = status.mentions.find(({ url }) => domNode.attribs.href === url); if (mention) { return ( e.stopPropagation()} > @{mention.username} ); } } if (classes?.includes('hashtag')) { const child = domToReact(domNode.children as DOMNode[]); const hashtag = typeof child === 'string' ? child.replace(/^#/, '') : undefined; if (hashtag) { return ; } } return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions e.stopPropagation()} rel='nofollow noopener' target='_blank' title={domNode.attribs.href} > {domToReact(domNode.children as DOMNode[], options)} ); } }, }; const spoilerText = status.spoilerMapHtml && status.currentLanguage ? status.spoilerMapHtml[status.currentLanguage] || status.spoilerHtml : status.spoilerHtml; const content = parse(parsedHtml, options); const direction = getTextDirection(status.search_index); const className = clsx(baseClassName, { 'cursor-pointer': onClick, 'whitespace-normal': withSpoiler, 'max-h-[200px]': collapsed, 'leading-normal big-emoji': onlyEmoji, }); const expandable = !displaySpoilers; const expanded = !withSpoiler || status.expanded || false; const output = []; if (spoilerText) { output.push( {expandable && ( )} , ); } if (expandable && !expanded) return <>{output}; if (onClick) { output.push( {content} , ); if (collapsed) { output.push(); } let hasPoll = false; if (status.poll_id) { hasPoll = true; output.push(); } return
{output}
; } else { output.push( {content} , ); if (status.poll_id) { output.push(); } return <>{output}; } }); export { StatusContent as default };