Spoiler: Simplify opening logic (#3816)

This commit is contained in:
Alexander Zinchuk 2023-09-08 18:39:18 +02:00
parent a32251a2b3
commit 2496ff8dcb
6 changed files with 33 additions and 54 deletions

View File

@ -10,6 +10,7 @@ import trimText from '../../util/trimText';
import { extractMessageText, getMessageText, stripCustomEmoji } from '../../global/helpers';
import { renderTextWithEntities } from './helpers/renderTextWithEntities';
import useSyncEffect from '../../hooks/useSyncEffect';
import useUniqueId from '../../hooks/useUniqueId';
interface OwnProps {
messageOrStory: ApiMessage | ApiStory;
@ -57,6 +58,8 @@ function MessageText({
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
const { text, entities } = adaptedFormattedText || {};
const containerId = useUniqueId();
useSyncEffect(() => {
textCacheBusterRef.current += 1;
}, [text, entities]);
@ -87,7 +90,7 @@ function MessageText({
highlight,
emojiSize,
shouldRenderAsHtml,
messageId: messageOrStory.id,
containerId,
isSimple,
isProtected,
observeIntersectionForLoading,

View File

@ -4,6 +4,7 @@ import type { TextPart } from '../../../types';
import type { LangFn } from '../../../hooks/useLang';
import {
getMessageKey,
getMessageSummaryDescription,
getMessageSummaryEmoji,
getMessageSummaryText,
@ -23,6 +24,7 @@ export function renderMessageText({
isProtected,
forcePlayback,
shouldRenderAsHtml,
isForMediaViewer,
} : {
message: ApiMessage;
highlight?: string;
@ -32,6 +34,7 @@ export function renderMessageText({
isProtected?: boolean;
forcePlayback?: boolean;
shouldRenderAsHtml?: boolean;
isForMediaViewer?: boolean;
}) {
const { text, entities } = message.content.text || {};
@ -40,13 +43,15 @@ export function renderMessageText({
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined;
}
const messageKey = getMessageKey(message);
return renderTextWithEntities({
text: trimText(text, truncateLength),
entities,
highlight,
emojiSize,
shouldRenderAsHtml,
messageId: message.id,
containerId: `${isForMediaViewer ? 'mv-' : ''}${messageKey}`,
isSimple,
isProtected,
forcePlayback,

View File

@ -33,7 +33,7 @@ export function renderTextWithEntities({
highlight,
emojiSize,
shouldRenderAsHtml,
messageId,
containerId,
isSimple,
isProtected,
observeIntersectionForLoading,
@ -49,7 +49,7 @@ export function renderTextWithEntities({
highlight?: string;
emojiSize?: number;
shouldRenderAsHtml?: boolean;
messageId?: number;
containerId?: string;
isSimple?: boolean;
isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn;
@ -138,7 +138,7 @@ export function renderTextWithEntities({
entityContent,
nestedEntityContent,
highlight,
messageId,
containerId,
isSimple,
isProtected,
observeIntersectionForLoading,
@ -320,7 +320,7 @@ function processEntity({
entityContent,
nestedEntityContent,
highlight,
messageId,
containerId,
isSimple,
isProtected,
observeIntersectionForLoading,
@ -336,7 +336,7 @@ function processEntity({
entityContent: TextPart;
nestedEntityContent: TextPart[];
highlight?: string;
messageId?: number;
containerId?: string;
isSimple?: boolean;
isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn;
@ -492,7 +492,7 @@ function processEntity({
case ApiMessageEntityTypes.Underline:
return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Spoiler:
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
return <Spoiler containerId={containerId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji:
return (
<CustomEmoji

View File

@ -12,84 +12,55 @@ import './Spoiler.scss';
type OwnProps = {
children?: React.ReactNode;
messageId?: number;
containerId?: string;
};
const READING_SYMBOLS_PER_SECOND = 23; // Heuristics
const MIN_HIDE_TIMEOUT = 5000; // 5s
const MAX_HIDE_TIMEOUT = 60000; // 1m
const actionsByMessageId: Map<number, {
reveal: VoidFunction;
conceal: VoidFunction;
contentLength: number;
}[]> = new Map();
const revealByContainerId: Map<string, VoidFunction[]> = new Map();
const buildClassName = createClassNameBuilder('Spoiler');
const Spoiler: FC<OwnProps> = ({
children,
messageId,
containerId,
}) => {
// eslint-disable-next-line no-null/no-null
const contentRef = useRef<HTMLDivElement>(null);
const [isRevealed, reveal, conceal] = useFlag();
const getContentLength = useLastCallback(() => {
if (!contentRef.current) {
return 0;
}
const textLength = contentRef.current.textContent?.length || 0;
const emojiCount = contentRef.current.querySelectorAll('.emoji').length;
// Optimization: ignore alt, assume that viewing emoji takes same time as viewing 4 characters
return textLength + emojiCount * 4;
});
const [isRevealed, revealSpoiler] = useFlag();
const handleClick = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!containerId) return;
e.preventDefault();
e.stopPropagation();
actionsByMessageId.get(messageId!)?.forEach((actions) => actions.reveal());
const totalContentLength = actionsByMessageId.get(messageId!)
?.reduce((acc, actions) => acc + actions.contentLength, 0) || 0;
const readingMs = Math.round(totalContentLength / READING_SYMBOLS_PER_SECOND) * 1000;
const timeoutMs = Math.max(MIN_HIDE_TIMEOUT, Math.min(readingMs, MAX_HIDE_TIMEOUT));
setTimeout(() => {
actionsByMessageId.get(messageId!)?.forEach((actions) => actions.conceal());
conceal();
}, timeoutMs);
revealByContainerId.get(containerId)?.forEach((reveal) => reveal());
});
useEffect(() => {
if (!messageId) {
if (!containerId) {
return undefined;
}
const contentLength = getContentLength();
if (actionsByMessageId.has(messageId)) {
actionsByMessageId.get(messageId)!.push({ reveal, conceal, contentLength });
if (revealByContainerId.has(containerId)) {
revealByContainerId.get(containerId)!.push(revealSpoiler);
} else {
actionsByMessageId.set(messageId, [{ reveal, conceal, contentLength }]);
revealByContainerId.set(containerId, [revealSpoiler]);
}
return () => {
actionsByMessageId.delete(messageId);
revealByContainerId.delete(containerId);
};
}, [conceal, getContentLength, handleClick, isRevealed, messageId, reveal]);
}, [containerId]);
return (
<span
className={buildClassName(
'&',
!isRevealed && 'concealed',
!isRevealed && Boolean(messageId) && 'animated',
!isRevealed && Boolean(containerId) && 'animated',
)}
onClick={messageId && !isRevealed ? handleClick : undefined}
onClick={containerId && !isRevealed ? handleClick : undefined}
data-entity-type={ApiMessageEntityTypes.Spoiler}
>
<span className={buildClassName('content')} ref={contentRef}>

View File

@ -207,7 +207,7 @@ const MediaViewer: FC<StateProps> = ({
const prevMediaId = usePrevious(mediaId);
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
const prevBestImageData = usePrevious(bestImageData);
const textParts = message ? renderMessageText({ message, forcePlayback: true }) : undefined;
const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined;
const hasFooter = Boolean(textParts);
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;

View File

@ -147,7 +147,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
if (!message) return undefined;
const textParts = message.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText({ message, forcePlayback: true });
: renderMessageText({ message, forcePlayback: true, isForMediaViewer: true });
const hasFooter = Boolean(textParts);
const posterSize = message && calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);