Spoiler: Simplify opening logic (#3816)
This commit is contained in:
parent
a32251a2b3
commit
2496ff8dcb
@ -10,6 +10,7 @@ import trimText from '../../util/trimText';
|
|||||||
import { extractMessageText, getMessageText, stripCustomEmoji } from '../../global/helpers';
|
import { extractMessageText, getMessageText, stripCustomEmoji } from '../../global/helpers';
|
||||||
import { renderTextWithEntities } from './helpers/renderTextWithEntities';
|
import { renderTextWithEntities } from './helpers/renderTextWithEntities';
|
||||||
import useSyncEffect from '../../hooks/useSyncEffect';
|
import useSyncEffect from '../../hooks/useSyncEffect';
|
||||||
|
import useUniqueId from '../../hooks/useUniqueId';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
messageOrStory: ApiMessage | ApiStory;
|
messageOrStory: ApiMessage | ApiStory;
|
||||||
@ -57,6 +58,8 @@ function MessageText({
|
|||||||
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
|
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
|
||||||
const { text, entities } = adaptedFormattedText || {};
|
const { text, entities } = adaptedFormattedText || {};
|
||||||
|
|
||||||
|
const containerId = useUniqueId();
|
||||||
|
|
||||||
useSyncEffect(() => {
|
useSyncEffect(() => {
|
||||||
textCacheBusterRef.current += 1;
|
textCacheBusterRef.current += 1;
|
||||||
}, [text, entities]);
|
}, [text, entities]);
|
||||||
@ -87,7 +90,7 @@ function MessageText({
|
|||||||
highlight,
|
highlight,
|
||||||
emojiSize,
|
emojiSize,
|
||||||
shouldRenderAsHtml,
|
shouldRenderAsHtml,
|
||||||
messageId: messageOrStory.id,
|
containerId,
|
||||||
isSimple,
|
isSimple,
|
||||||
isProtected,
|
isProtected,
|
||||||
observeIntersectionForLoading,
|
observeIntersectionForLoading,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { TextPart } from '../../../types';
|
|||||||
import type { LangFn } from '../../../hooks/useLang';
|
import type { LangFn } from '../../../hooks/useLang';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getMessageKey,
|
||||||
getMessageSummaryDescription,
|
getMessageSummaryDescription,
|
||||||
getMessageSummaryEmoji,
|
getMessageSummaryEmoji,
|
||||||
getMessageSummaryText,
|
getMessageSummaryText,
|
||||||
@ -23,6 +24,7 @@ export function renderMessageText({
|
|||||||
isProtected,
|
isProtected,
|
||||||
forcePlayback,
|
forcePlayback,
|
||||||
shouldRenderAsHtml,
|
shouldRenderAsHtml,
|
||||||
|
isForMediaViewer,
|
||||||
} : {
|
} : {
|
||||||
message: ApiMessage;
|
message: ApiMessage;
|
||||||
highlight?: string;
|
highlight?: string;
|
||||||
@ -32,6 +34,7 @@ export function renderMessageText({
|
|||||||
isProtected?: boolean;
|
isProtected?: boolean;
|
||||||
forcePlayback?: boolean;
|
forcePlayback?: boolean;
|
||||||
shouldRenderAsHtml?: boolean;
|
shouldRenderAsHtml?: boolean;
|
||||||
|
isForMediaViewer?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { text, entities } = message.content.text || {};
|
const { text, entities } = message.content.text || {};
|
||||||
|
|
||||||
@ -40,13 +43,15 @@ export function renderMessageText({
|
|||||||
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined;
|
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageKey = getMessageKey(message);
|
||||||
|
|
||||||
return renderTextWithEntities({
|
return renderTextWithEntities({
|
||||||
text: trimText(text, truncateLength),
|
text: trimText(text, truncateLength),
|
||||||
entities,
|
entities,
|
||||||
highlight,
|
highlight,
|
||||||
emojiSize,
|
emojiSize,
|
||||||
shouldRenderAsHtml,
|
shouldRenderAsHtml,
|
||||||
messageId: message.id,
|
containerId: `${isForMediaViewer ? 'mv-' : ''}${messageKey}`,
|
||||||
isSimple,
|
isSimple,
|
||||||
isProtected,
|
isProtected,
|
||||||
forcePlayback,
|
forcePlayback,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export function renderTextWithEntities({
|
|||||||
highlight,
|
highlight,
|
||||||
emojiSize,
|
emojiSize,
|
||||||
shouldRenderAsHtml,
|
shouldRenderAsHtml,
|
||||||
messageId,
|
containerId,
|
||||||
isSimple,
|
isSimple,
|
||||||
isProtected,
|
isProtected,
|
||||||
observeIntersectionForLoading,
|
observeIntersectionForLoading,
|
||||||
@ -49,7 +49,7 @@ export function renderTextWithEntities({
|
|||||||
highlight?: string;
|
highlight?: string;
|
||||||
emojiSize?: number;
|
emojiSize?: number;
|
||||||
shouldRenderAsHtml?: boolean;
|
shouldRenderAsHtml?: boolean;
|
||||||
messageId?: number;
|
containerId?: string;
|
||||||
isSimple?: boolean;
|
isSimple?: boolean;
|
||||||
isProtected?: boolean;
|
isProtected?: boolean;
|
||||||
observeIntersectionForLoading?: ObserveFn;
|
observeIntersectionForLoading?: ObserveFn;
|
||||||
@ -138,7 +138,7 @@ export function renderTextWithEntities({
|
|||||||
entityContent,
|
entityContent,
|
||||||
nestedEntityContent,
|
nestedEntityContent,
|
||||||
highlight,
|
highlight,
|
||||||
messageId,
|
containerId,
|
||||||
isSimple,
|
isSimple,
|
||||||
isProtected,
|
isProtected,
|
||||||
observeIntersectionForLoading,
|
observeIntersectionForLoading,
|
||||||
@ -320,7 +320,7 @@ function processEntity({
|
|||||||
entityContent,
|
entityContent,
|
||||||
nestedEntityContent,
|
nestedEntityContent,
|
||||||
highlight,
|
highlight,
|
||||||
messageId,
|
containerId,
|
||||||
isSimple,
|
isSimple,
|
||||||
isProtected,
|
isProtected,
|
||||||
observeIntersectionForLoading,
|
observeIntersectionForLoading,
|
||||||
@ -336,7 +336,7 @@ function processEntity({
|
|||||||
entityContent: TextPart;
|
entityContent: TextPart;
|
||||||
nestedEntityContent: TextPart[];
|
nestedEntityContent: TextPart[];
|
||||||
highlight?: string;
|
highlight?: string;
|
||||||
messageId?: number;
|
containerId?: string;
|
||||||
isSimple?: boolean;
|
isSimple?: boolean;
|
||||||
isProtected?: boolean;
|
isProtected?: boolean;
|
||||||
observeIntersectionForLoading?: ObserveFn;
|
observeIntersectionForLoading?: ObserveFn;
|
||||||
@ -492,7 +492,7 @@ function processEntity({
|
|||||||
case ApiMessageEntityTypes.Underline:
|
case ApiMessageEntityTypes.Underline:
|
||||||
return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
|
return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
|
||||||
case ApiMessageEntityTypes.Spoiler:
|
case ApiMessageEntityTypes.Spoiler:
|
||||||
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
|
return <Spoiler containerId={containerId}>{renderNestedMessagePart()}</Spoiler>;
|
||||||
case ApiMessageEntityTypes.CustomEmoji:
|
case ApiMessageEntityTypes.CustomEmoji:
|
||||||
return (
|
return (
|
||||||
<CustomEmoji
|
<CustomEmoji
|
||||||
|
|||||||
@ -12,84 +12,55 @@ import './Spoiler.scss';
|
|||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
messageId?: number;
|
containerId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const READING_SYMBOLS_PER_SECOND = 23; // Heuristics
|
const revealByContainerId: Map<string, VoidFunction[]> = new Map();
|
||||||
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 buildClassName = createClassNameBuilder('Spoiler');
|
const buildClassName = createClassNameBuilder('Spoiler');
|
||||||
|
|
||||||
const Spoiler: FC<OwnProps> = ({
|
const Spoiler: FC<OwnProps> = ({
|
||||||
children,
|
children,
|
||||||
messageId,
|
containerId,
|
||||||
}) => {
|
}) => {
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isRevealed, reveal, conceal] = useFlag();
|
const [isRevealed, revealSpoiler] = 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 handleClick = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleClick = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
if (!containerId) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
actionsByMessageId.get(messageId!)?.forEach((actions) => actions.reveal());
|
revealByContainerId.get(containerId)?.forEach((reveal) => 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messageId) {
|
if (!containerId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentLength = getContentLength();
|
if (revealByContainerId.has(containerId)) {
|
||||||
|
revealByContainerId.get(containerId)!.push(revealSpoiler);
|
||||||
if (actionsByMessageId.has(messageId)) {
|
|
||||||
actionsByMessageId.get(messageId)!.push({ reveal, conceal, contentLength });
|
|
||||||
} else {
|
} else {
|
||||||
actionsByMessageId.set(messageId, [{ reveal, conceal, contentLength }]);
|
revealByContainerId.set(containerId, [revealSpoiler]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
actionsByMessageId.delete(messageId);
|
revealByContainerId.delete(containerId);
|
||||||
};
|
};
|
||||||
}, [conceal, getContentLength, handleClick, isRevealed, messageId, reveal]);
|
}, [containerId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={buildClassName(
|
className={buildClassName(
|
||||||
'&',
|
'&',
|
||||||
!isRevealed && 'concealed',
|
!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}
|
data-entity-type={ApiMessageEntityTypes.Spoiler}
|
||||||
>
|
>
|
||||||
<span className={buildClassName('content')} ref={contentRef}>
|
<span className={buildClassName('content')} ref={contentRef}>
|
||||||
|
|||||||
@ -207,7 +207,7 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
const prevMediaId = usePrevious(mediaId);
|
const prevMediaId = usePrevious(mediaId);
|
||||||
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
|
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
|
||||||
const prevBestImageData = usePrevious(bestImageData);
|
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 hasFooter = Boolean(textParts);
|
||||||
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;
|
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;
|
||||||
|
|
||||||
|
|||||||
@ -147,7 +147,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
|||||||
if (!message) return undefined;
|
if (!message) return undefined;
|
||||||
const textParts = message.content.action?.type === 'suggestProfilePhoto'
|
const textParts = message.content.action?.type === 'suggestProfilePhoto'
|
||||||
? lang('Conversation.SuggestedPhotoTitle')
|
? lang('Conversation.SuggestedPhotoTitle')
|
||||||
: renderMessageText({ message, forcePlayback: true });
|
: renderMessageText({ message, forcePlayback: true, isForMediaViewer: true });
|
||||||
|
|
||||||
const hasFooter = Boolean(textParts);
|
const hasFooter = Boolean(textParts);
|
||||||
const posterSize = message && calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
|
const posterSize = message && calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user