334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useState, useRef, useLayoutEffect, useMemo } from 'react';
|
|
import { FormattedMessage } from 'react-intl';
|
|
|
|
import Icon from '@/components/ui/icon';
|
|
import Stack from '@/components/ui/stack';
|
|
import Emojify from '@/features/emoji/emojify';
|
|
import QuotedStatus from '@/features/status/containers/quoted-status-container';
|
|
import { useFrontendConfig } from '@/hooks/use-frontend-config';
|
|
import { useLocalStatusTranslation } from '@/queries/statuses/use-local-status-translation';
|
|
import { useStatusTranslation } from '@/queries/statuses/use-status-translation';
|
|
import { useSettings } from '@/stores/settings';
|
|
import { useStatusMeta, useStatusMetaActions } from '@/stores/status-meta';
|
|
import { onlyEmoji as isOnlyEmoji } from '@/utils/rich-content';
|
|
|
|
import { getTextDirection } from '../utils/rtl';
|
|
|
|
import HashtagsBar from './hashtags-bar';
|
|
import Markup from './markup';
|
|
import OutlineBox from './outline-box';
|
|
import { parseContent } from './parsed-content';
|
|
import { ParsedMfm } from './parsed-mfm';
|
|
import Poll from './polls/poll';
|
|
import QuotedStatusIndicator from './quoted-status-indicator';
|
|
import StatusMedia from './status-media';
|
|
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
|
import TranslateButton from './translate-button';
|
|
|
|
import type { Sizes } from '@/components/ui/text';
|
|
import type { MinifiedStatus } from '@/reducers/statuses';
|
|
|
|
const BIG_EMOJI_LIMIT = 10;
|
|
|
|
interface IReadMoreButton {
|
|
onClick?: React.MouseEventHandler;
|
|
preview?: boolean;
|
|
}
|
|
|
|
/** Button to expand a truncated status (due to too much content) */
|
|
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick, preview }) => (
|
|
<div className='⁂-read-more-button__container'>
|
|
<div className='⁂-read-more-button__gradient' />
|
|
{!preview && (
|
|
<button className='⁂-read-more-button' onClick={onClick}>
|
|
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
|
|
<Icon
|
|
className='inline-block size-5'
|
|
src={require('@phosphor-icons/core/regular/caret-right.svg')}
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
interface IStatusContent {
|
|
status: MinifiedStatus;
|
|
onClick?: () => void;
|
|
collapsable?: boolean;
|
|
translatable?: boolean;
|
|
textSize?: Sizes;
|
|
isQuote?: boolean;
|
|
preview?: boolean;
|
|
withMedia?: boolean;
|
|
compose?: boolean;
|
|
}
|
|
|
|
/** Renders the text content of a status */
|
|
const StatusContent: React.FC<IStatusContent> = React.memo(
|
|
({
|
|
status,
|
|
onClick,
|
|
collapsable = false,
|
|
translatable,
|
|
textSize = 'md',
|
|
isQuote = false,
|
|
preview,
|
|
withMedia,
|
|
compose = false,
|
|
}) => {
|
|
const { urlPrivacy, displaySpoilers, renderMfm } = useSettings();
|
|
const { greentext } = useFrontendConfig();
|
|
|
|
const [collapsed, setCollapsed] = useState<boolean | null>(null);
|
|
const [onlyEmoji, setOnlyEmoji] = useState(false);
|
|
const [lineClamp, setLineClamp] = useState(true);
|
|
|
|
const contentNode = useRef<HTMLDivElement>(null);
|
|
const spoilerNode = useRef<HTMLSpanElement>(null);
|
|
|
|
const { collapseStatuses, expandStatuses } = useStatusMetaActions();
|
|
const statusMeta = useStatusMeta(status.id);
|
|
const { data: translation } = useStatusTranslation(status.id, statusMeta.targetLanguage);
|
|
const { data: localTranslation } = useLocalStatusTranslation(
|
|
status.id,
|
|
statusMeta.localTargetLanguage,
|
|
);
|
|
|
|
const withSpoiler = status.spoiler_text?.length > 0;
|
|
const expanded = !withSpoiler || (statusMeta.expanded ?? false);
|
|
|
|
const maybeSetCollapsed = (): void => {
|
|
if (!contentNode.current) return;
|
|
|
|
if (collapsable || preview) {
|
|
// 20px * x lines (+ 2px padding at the top)
|
|
setCollapsed(
|
|
contentNode.current.clientHeight >= (preview ? 82 : isQuote ? 202 : 282) ||
|
|
contentNode.current.scrollWidth > contentNode.current.clientWidth,
|
|
);
|
|
}
|
|
};
|
|
|
|
const maybeSetOnlyEmoji = (): void => {
|
|
if (!contentNode.current) return;
|
|
const only = isOnlyEmoji(contentNode.current, BIG_EMOJI_LIMIT, true);
|
|
|
|
if (only !== onlyEmoji) {
|
|
setOnlyEmoji(only);
|
|
}
|
|
};
|
|
|
|
const toggleExpanded: React.MouseEventHandler = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (expanded) {
|
|
collapseStatuses([status.id]);
|
|
setCollapsed(null);
|
|
} else expandStatuses([status.id]);
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
maybeSetCollapsed();
|
|
maybeSetOnlyEmoji();
|
|
}, [expanded]);
|
|
|
|
const content = useMemo(
|
|
(): string =>
|
|
localTranslation
|
|
? localTranslation.content
|
|
: translation
|
|
? translation.content
|
|
: status.content_map && statusMeta.currentLanguage
|
|
? status.content_map[statusMeta.currentLanguage] || status.content
|
|
: status.content,
|
|
[status.content, localTranslation, translation, statusMeta.currentLanguage],
|
|
);
|
|
|
|
const { content: parsedContent, hashtags } = useMemo(() => {
|
|
if (
|
|
renderMfm &&
|
|
!localTranslation &&
|
|
!translation &&
|
|
status.content_type === 'text/x.misskeymarkdown' &&
|
|
status.text
|
|
) {
|
|
return {
|
|
content: (
|
|
<ParsedMfm text={status.text} emojis={status.emojis} mentions={status.mentions} />
|
|
),
|
|
hashtags: [],
|
|
};
|
|
}
|
|
|
|
return parseContent(
|
|
{
|
|
html: content,
|
|
mentions: status.mentions,
|
|
hasQuote: !!status.quote_id,
|
|
emojis: status.emojis,
|
|
cleanUrls: urlPrivacy.clearLinksInContent,
|
|
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
|
|
displayTargetHost: urlPrivacy.displayTargetHost,
|
|
greentext,
|
|
speakAsCat: status.account.speak_as_cat,
|
|
},
|
|
true,
|
|
);
|
|
}, [content, renderMfm]);
|
|
|
|
const spoilerText =
|
|
status.spoiler_text_map && statusMeta.currentLanguage
|
|
? status.spoiler_text_map[statusMeta.currentLanguage] || status.spoiler_text
|
|
: status.spoiler_text;
|
|
|
|
useLayoutEffect(() => {
|
|
setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96);
|
|
}, [spoilerText]);
|
|
|
|
const direction = getTextDirection(status.search_index);
|
|
const className = useMemo(
|
|
() =>
|
|
clsx('⁂-status-content', {
|
|
'overflow-hidden': collapsed,
|
|
'max-h-[200px]': collapsed && !isQuote && !preview,
|
|
'max-h-[120px]': collapsed && isQuote,
|
|
'max-h-[80px]': collapsed && preview,
|
|
'max-h-[282px]': collapsable && collapsed === null && !isQuote && !preview,
|
|
'max-h-[202px]': collapsable && collapsed === null && isQuote,
|
|
'max-h-[82px]': collapsed === null && preview,
|
|
'big-emoji leading-normal': onlyEmoji,
|
|
'⁂-status-content--expanded': !collapsable,
|
|
'⁂-status-content--quote': isQuote,
|
|
'⁂-status-content--preview': preview,
|
|
'⁂-status-content--poll': !!status.poll_id,
|
|
}),
|
|
[collapsed, onlyEmoji],
|
|
);
|
|
|
|
const expandable = !displaySpoilers;
|
|
|
|
const output = [];
|
|
|
|
if (spoilerText) {
|
|
output.push(
|
|
<p
|
|
className={clsx('⁂-status-title', {
|
|
'⁂-status-title--clamp': !expanded && lineClamp,
|
|
})}
|
|
key='spoiler'
|
|
aria-expanded={expanded}
|
|
{...(expandable ? { onClick: toggleExpanded, role: 'button' } : {})}
|
|
>
|
|
<span ref={spoilerNode}>
|
|
<Emojify text={spoilerText} emojis={status.emojis} />
|
|
</span>
|
|
{expandable && (
|
|
<button onClick={toggleExpanded}>
|
|
<Icon src={require('@phosphor-icons/core/regular/caret-down.svg')} />
|
|
<span>
|
|
{expanded ? (
|
|
<FormattedMessage id='status.spoiler.collapse' defaultMessage='Collapse' />
|
|
) : (
|
|
<FormattedMessage id='status.spoiler.expand' defaultMessage='Expand' />
|
|
)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</p>,
|
|
);
|
|
}
|
|
|
|
if (!expandable || expanded) {
|
|
let quote;
|
|
|
|
if (withMedia && status.quote_id) {
|
|
if (isQuote) {
|
|
quote = <QuotedStatusIndicator statusId={status.quote_id} statusUrl={status.quote_url} />;
|
|
} else if (!(status.quote_visible ?? true)) {
|
|
quote = (
|
|
<OutlineBox>
|
|
<p>
|
|
<FormattedMessage
|
|
id='statuses.quote_tombstone'
|
|
defaultMessage='Post is unavailable.'
|
|
/>
|
|
</p>
|
|
</OutlineBox>
|
|
);
|
|
} else {
|
|
quote = <QuotedStatus statusId={status.quote_id} />;
|
|
}
|
|
}
|
|
|
|
const media = (quote ??
|
|
status.card ??
|
|
(withMedia && status.media_attachments.length > 0)) && (
|
|
<Stack space={4} key='media'>
|
|
{((withMedia && status.media_attachments.length > 0) ?? (status.card && !quote)) && (
|
|
<div className='relative has-[div[data-testid="sensitive-overlay"]]:min-h-24'>
|
|
<SensitiveContentOverlay status={status} />
|
|
{withMedia && <StatusMedia status={status} muted={compose} />}
|
|
</div>
|
|
)}
|
|
|
|
{quote}
|
|
</Stack>
|
|
);
|
|
|
|
if (status.content) {
|
|
output.push(
|
|
<Markup
|
|
ref={contentNode}
|
|
tabIndex={0}
|
|
key='content'
|
|
className={className}
|
|
direction={direction}
|
|
lang={status.language ?? undefined}
|
|
size={textSize}
|
|
>
|
|
{parsedContent}
|
|
</Markup>,
|
|
);
|
|
}
|
|
|
|
if (collapsed) {
|
|
output.push(<ReadMoreButton onClick={onClick} key='read-more' preview={preview} />);
|
|
}
|
|
|
|
if (status.poll_id) {
|
|
output.push(
|
|
<Poll
|
|
id={status.poll_id}
|
|
key='poll'
|
|
status={status}
|
|
language={statusMeta.currentLanguage}
|
|
truncate={collapsable}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (translatable) {
|
|
output.push(<TranslateButton status={status} key='translate' />);
|
|
}
|
|
|
|
if (media) {
|
|
output.push(media);
|
|
}
|
|
|
|
if (hashtags.length) {
|
|
output.push(<HashtagsBar key='hashtags' hashtags={hashtags} />);
|
|
}
|
|
}
|
|
|
|
if (onClick) {
|
|
return <div className='⁂-status-content__container'>{output}</div>;
|
|
} else {
|
|
return <>{output}</>;
|
|
}
|
|
},
|
|
);
|
|
|
|
export { StatusContent as default };
|