Introduce Spoilers (#1670)

This commit is contained in:
Alexander Zinchuk 2022-01-27 04:08:18 +01:00
parent 58453f46b5
commit a55fbdfcc4
21 changed files with 423 additions and 155 deletions

View File

@ -31,7 +31,6 @@ import {
} from '../../types'; } from '../../types';
import { import {
CONTENT_NOT_SUPPORTED,
DELETED_COMMENTS_CHANNEL_ID, DELETED_COMMENTS_CHANNEL_ID,
LOCAL_MESSAGE_ID_BASE, LOCAL_MESSAGE_ID_BASE,
SERVICE_NOTIFICATIONS_USER_ID, SERVICE_NOTIFICATIONS_USER_ID,
@ -264,12 +263,6 @@ export function buildMessageTextContent(
message: string, message: string,
entities?: GramJs.TypeMessageEntity[], entities?: GramJs.TypeMessageEntity[],
): ApiFormattedText { ): ApiFormattedText {
if (entities?.some((e) => e instanceof GramJs.MessageEntitySpoiler)) {
return {
text: CONTENT_NOT_SUPPORTED,
};
}
return { return {
text: message, text: message,
...(entities && { entities: entities.map(buildApiMessageEntity) }), ...(entities && { entities: entities.map(buildApiMessageEntity) }),

View File

@ -213,6 +213,7 @@ export enum ApiMessageEntityTypes {
TextUrl = 'MessageEntityTextUrl', TextUrl = 'MessageEntityTextUrl',
Url = 'MessageEntityUrl', Url = 'MessageEntityUrl',
Underline = 'MessageEntityUnderline', Underline = 'MessageEntityUnderline',
Spoiler = 'MessageEntitySpoiler',
Unknown = 'MessageEntityUnknown', Unknown = 'MessageEntityUnknown',
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -5,7 +5,6 @@ import { ApiUser, ApiMessage, ApiChat } from '../../api/types';
import { import {
getMessageMediaHash, getMessageMediaHash,
isActionMessage, isActionMessage,
getMessageSummaryText,
getSenderTitle, getSenderTitle,
getMessageRoundVideo, getMessageRoundVideo,
} from '../../modules/helpers'; } from '../../modules/helpers';
@ -16,6 +15,7 @@ import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserve
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useWebpThumbnail from '../../hooks/useWebpThumbnail'; import useWebpThumbnail from '../../hooks/useWebpThumbnail';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import { renderMessageSummary } from './helpers/renderMessageText';
import ActionMessage from '../middle/ActionMessage'; import ActionMessage from '../middle/ActionMessage';
@ -71,7 +71,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
) : isActionMessage(message) ? ( ) : isActionMessage(message) ? (
<ActionMessage message={message} isEmbedded /> <ActionMessage message={message} isEmbedded />
) : ( ) : (
renderText(getMessageSummaryText(lang, message, Boolean(mediaThumbnail))) renderMessageSummary(lang, message, Boolean(mediaThumbnail))
)} )}
</p> </p>
<div className="message-title" dir="auto">{renderText(senderTitle || title || NBSP)}</div> <div className="message-title" dir="auto">{renderText(senderTitle || title || NBSP)}</div>

View File

@ -2,12 +2,16 @@ import React, { FC, memo, useCallback } from '../../lib/teact/teact';
import { ApiMessage, ApiWebPage } from '../../api/types'; import { ApiMessage, ApiWebPage } from '../../api/types';
import { getFirstLinkInMessage, getMessageSummaryText, getMessageWebPage } from '../../modules/helpers'; import {
getFirstLinkInMessage, getMessageText,
getMessageWebPage,
} from '../../modules/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import trimText from '../../util/trimText'; import trimText from '../../util/trimText';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { formatPastTimeShort } from '../../util/dateFormat'; import { formatPastTimeShort } from '../../util/dateFormat';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import { renderMessageSummary, TextPart } from './helpers/renderMessageText';
import Media from './Media'; import Media from './Media';
import Link from '../ui/Link'; import Link from '../ui/Link';
@ -24,24 +28,27 @@ type OwnProps = {
onMessageClick: (messageId: number, chatId: string) => void; onMessageClick: (messageId: number, chatId: string) => void;
}; };
type ApiWebPageWithFormatted = ApiWebPage & { formattedDescription?: TextPart[] };
const WebLink: FC<OwnProps> = ({ const WebLink: FC<OwnProps> = ({
message, senderTitle, isProtected, onMessageClick, message, senderTitle, isProtected, onMessageClick,
}) => { }) => {
const lang = useLang(); const lang = useLang();
let linkData: ApiWebPage | undefined = getMessageWebPage(message); let linkData: ApiWebPageWithFormatted | undefined = getMessageWebPage(message);
if (!linkData) { if (!linkData) {
const link = getFirstLinkInMessage(message); const link = getFirstLinkInMessage(message);
if (link) { if (link) {
const { url, domain } = link; const { url, domain } = link;
const messageText = getMessageSummaryText(lang, message);
linkData = { linkData = {
siteName: domain.replace(/^www./, ''), siteName: domain.replace(/^www./, ''),
url: url.includes('://') ? url : url.includes('@') ? `mailto:${url}` : `http://${url}`, url: url.includes('://') ? url : url.includes('@') ? `mailto:${url}` : `http://${url}`,
description: messageText !== url ? messageText : undefined, formattedDescription: getMessageText(message) !== url
} as ApiWebPage; ? renderMessageSummary(lang, message, undefined, undefined, MAX_TEXT_LENGTH, true)
: undefined,
} as ApiWebPageWithFormatted;
} }
} }
@ -59,11 +66,12 @@ const WebLink: FC<OwnProps> = ({
displayUrl, displayUrl,
title, title,
description, description,
formattedDescription,
photo, photo,
video, video,
} = linkData; } = linkData;
const truncatedDescription = !senderTitle && trimText(description, MAX_TEXT_LENGTH); const truncatedDescription = !senderTitle && description && trimText(description, MAX_TEXT_LENGTH);
const className = buildClassName( const className = buildClassName(
'WebLink scroll-item', 'WebLink scroll-item',
@ -83,9 +91,9 @@ const WebLink: FC<OwnProps> = ({
<Link isRtl={lang.isRtl} className="site-title" onClick={handleMessageClick}> <Link isRtl={lang.isRtl} className="site-title" onClick={handleMessageClick}>
{renderText(title || siteName || displayUrl)} {renderText(title || siteName || displayUrl)}
</Link> </Link>
{truncatedDescription && ( {(truncatedDescription || formattedDescription) && (
<Link isRtl={lang.isRtl} className="site-description" onClick={handleMessageClick}> <Link isRtl={lang.isRtl} className="site-description" onClick={handleMessageClick}>
{renderText(truncatedDescription)} {formattedDescription || (truncatedDescription && renderText(truncatedDescription))}
</Link> </Link>
)} )}
<SafeLink <SafeLink

View File

@ -6,14 +6,13 @@ import {
import { LangFn } from '../../../hooks/useLang'; import { LangFn } from '../../../hooks/useLang';
import { import {
getChatTitle, getChatTitle,
getMessageContent, getMessageContent, getMessageSummaryText,
getMessageSummaryText,
getUserFullName, getUserFullName,
isUserId, isUserId,
} from '../../../modules/helpers'; } from '../../../modules/helpers';
import trimText from '../../../util/trimText'; import trimText from '../../../util/trimText';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';
import { TextPart } from './renderMessageText'; import { renderMessageSummary, TextPart } from './renderMessageText';
import renderText from './renderText'; import renderText from './renderText';
import UserLink from '../UserLink'; import UserLink from '../UserLink';
@ -132,44 +131,46 @@ function renderProductContent(message: ApiMessage) {
} }
function renderMessageContent(lang: LangFn, message: ApiMessage, options: ActionMessageTextOptions = {}) { function renderMessageContent(lang: LangFn, message: ApiMessage, options: ActionMessageTextOptions = {}) {
const text = getMessageSummaryText(lang, message); const { maxTextLength, isEmbedded, asPlain } = options;
const text = asPlain
? [trimText(getMessageSummaryText(lang, message), maxTextLength)]
: renderMessageSummary(lang, message, undefined, undefined, maxTextLength, true);
const { const {
photo, video, document, sticker, photo, video, document, sticker,
} = getMessageContent(message); } = getMessageContent(message);
const { maxTextLength, isEmbedded, asPlain } = options;
const showQuotes = isEmbedded && text && !photo && !video && !document && !sticker; const showQuotes = isEmbedded && text && !photo && !video && !document && !sticker;
let messageText = trimText(text as string, maxTextLength)!; let messageText = text;
if (isEmbedded) { if (isEmbedded) {
if (photo) { if (photo) {
messageText = 'a photo'; messageText = ['a photo'];
} else if (video) { } else if (video) {
messageText = video.isGif ? 'a GIF' : 'a video'; messageText = [video.isGif ? 'a GIF' : 'a video'];
} else if (document) { } else if (document) {
messageText = 'a document'; messageText = ['a document'];
} else if (sticker) { } else if (sticker) {
messageText = text; messageText = text;
} }
} }
if (asPlain) { if (asPlain && messageText) {
return showQuotes ? `«${messageText}»` : messageText; return (showQuotes ? ['«', ...messageText, '»'] : messageText).join('');
} }
if (showQuotes) { if (showQuotes) {
return ( return (
<span> <span>
&laquo; &laquo;
<MessageLink className="action-link" message={message}>{renderText(messageText)}</MessageLink> <MessageLink className="action-link" message={message}>{messageText}</MessageLink>
&raquo; &raquo;
</span> </span>
); );
} }
return ( return (
<MessageLink className="action-link" message={message}>{renderText(messageText)}</MessageLink> <MessageLink className="action-link" message={message}>{messageText}</MessageLink>
); );
} }

View File

@ -4,15 +4,64 @@ import { getDispatch } from '../../../lib/teact/teactn';
import { ApiMessageEntity, ApiMessageEntityTypes, ApiMessage } from '../../../api/types'; import { ApiMessageEntity, ApiMessageEntityTypes, ApiMessage } from '../../../api/types';
import { getMessageText } from '../../../modules/helpers'; import {
import renderText from './renderText'; getMessageSummaryText,
getMessageSummaryDescription,
getMessageSummaryEmoji,
getMessageText,
TRUNCATED_SUMMARY_LENGTH,
} from '../../../modules/helpers';
import renderText, { TextFilter } from './renderText';
import MentionLink from '../../middle/message/MentionLink'; import MentionLink from '../../middle/message/MentionLink';
import SafeLink from '../SafeLink'; import SafeLink from '../SafeLink';
import Spoiler from '../spoiler/Spoiler';
import { LangFn } from '../../../hooks/useLang';
export type TextPart = string | Element; export type TextPart = string | Element;
export function renderMessageText(message: ApiMessage, highlight?: string, shouldRenderHqEmoji?: boolean) { export function renderMessageSummary(
lang: LangFn,
message: ApiMessage,
noEmoji = false,
highlight?: string,
truncateLength = TRUNCATED_SUMMARY_LENGTH,
shouldAddEllipsis?: boolean,
): TextPart[] {
const hasSpoilers = message.content.text?.entities?.some((l) => l.type === ApiMessageEntityTypes.Spoiler);
if (!hasSpoilers) {
let text = getMessageSummaryText(lang, message, noEmoji, truncateLength);
if (shouldAddEllipsis) {
text += '...';
}
if (highlight) {
return renderText(text, ['emoji', 'highlight'], {
highlight,
});
} else {
return renderText(text);
}
}
const text = renderMessageText(message, highlight, undefined, true, truncateLength);
const emoji = !noEmoji && getMessageSummaryEmoji(message);
const emojiWithSpace = emoji ? `${emoji} ` : '';
const description = getMessageSummaryDescription(lang, message, text);
return [
emojiWithSpace,
...(Array.isArray(description) ? description : [description]),
shouldAddEllipsis && '...',
].filter(Boolean);
}
export function renderMessageText(
message: ApiMessage,
highlight?: string,
shouldRenderHqEmoji?: boolean,
isSimple?: boolean,
truncateLength?: number,
) {
const formattedText = message.content.text; const formattedText = message.content.text;
if (!formattedText || !formattedText.text) { if (!formattedText || !formattedText.text) {
@ -21,7 +70,15 @@ export function renderMessageText(message: ApiMessage, highlight?: string, shoul
} }
const { text, entities } = formattedText; const { text, entities } = formattedText;
return renderTextWithEntities(text, entities, highlight, shouldRenderHqEmoji); return renderTextWithEntities(
truncateLength ? text.substr(0, truncateLength) : text,
entities,
highlight,
shouldRenderHqEmoji,
undefined,
message.id,
isSimple,
);
} }
interface IOrganizedEntity { interface IOrganizedEntity {
@ -102,9 +159,11 @@ export function renderTextWithEntities(
highlight?: string, highlight?: string,
shouldRenderHqEmoji?: boolean, shouldRenderHqEmoji?: boolean,
shouldRenderAsHtml?: boolean, shouldRenderAsHtml?: boolean,
messageId?: number,
isSimple?: boolean,
) { ) {
if (!entities || !entities.length) { if (!entities || !entities.length) {
return renderMessagePart(text, highlight, shouldRenderHqEmoji, shouldRenderAsHtml); return renderMessagePart(text, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple);
} }
const result: TextPart[] = []; const result: TextPart[] = [];
@ -133,7 +192,7 @@ export function renderTextWithEntities(
} }
if (textBefore) { if (textBefore) {
renderResult.push(...renderMessagePart( renderResult.push(...renderMessagePart(
textBefore, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, textBefore, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple,
) as TextPart[]); ) as TextPart[]);
} }
} }
@ -176,7 +235,7 @@ export function renderTextWithEntities(
// Render the entity itself // Render the entity itself
const newEntity = shouldRenderAsHtml const newEntity = shouldRenderAsHtml
? processEntityAsHtml(entity, entityContent, nestedEntityContent) ? processEntityAsHtml(entity, entityContent, nestedEntityContent)
: processEntity(entity, entityContent, nestedEntityContent); : processEntity(entity, entityContent, nestedEntityContent, highlight, messageId, isSimple);
if (Array.isArray(newEntity)) { if (Array.isArray(newEntity)) {
renderResult.push(...newEntity); renderResult.push(...newEntity);
@ -193,7 +252,7 @@ export function renderTextWithEntities(
} }
if (textAfter) { if (textAfter) {
renderResult.push(...renderMessagePart( renderResult.push(...renderMessagePart(
textAfter, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, textAfter, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple,
) as TextPart[]); ) as TextPart[]);
} }
} }
@ -226,19 +285,36 @@ function processEntity(
entity: ApiMessageEntity, entity: ApiMessageEntity,
entityContent: TextPart, entityContent: TextPart,
nestedEntityContent: TextPart[], nestedEntityContent: TextPart[],
highlight?: string,
messageId?: number,
isSimple?: boolean,
) { ) {
const entityText = typeof entityContent === 'string' && entityContent; const entityText = typeof entityContent === 'string' && entityContent;
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
function renderNestedMessagePart() {
return renderMessagePart(
renderedContent, highlight, undefined, undefined, isSimple,
);
}
if (!entityText) { if (!entityText) {
return renderMessagePart(renderedContent); return renderNestedMessagePart();
}
if (isSimple) {
const text = renderNestedMessagePart();
if (entity.type === ApiMessageEntityTypes.Spoiler) {
return <Spoiler isInactive>{text}</Spoiler>;
}
return text;
} }
switch (entity.type) { switch (entity.type) {
case ApiMessageEntityTypes.Bold: case ApiMessageEntityTypes.Bold:
return <strong>{renderMessagePart(renderedContent)}</strong>; return <strong>{renderNestedMessagePart()}</strong>;
case ApiMessageEntityTypes.Blockquote: case ApiMessageEntityTypes.Blockquote:
return <blockquote>{renderMessagePart(renderedContent)}</blockquote>; return <blockquote>{renderNestedMessagePart()}</blockquote>;
case ApiMessageEntityTypes.BotCommand: case ApiMessageEntityTypes.BotCommand:
return ( return (
<a <a
@ -246,7 +322,7 @@ function processEntity(
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Hashtag: case ApiMessageEntityTypes.Hashtag:
@ -256,7 +332,7 @@ function processEntity(
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Cashtag: case ApiMessageEntityTypes.Cashtag:
@ -266,11 +342,11 @@ function processEntity(
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Code: case ApiMessageEntityTypes.Code:
return <code className="text-entity-code">{renderMessagePart(renderedContent)}</code>; return <code className="text-entity-code">{renderNestedMessagePart()}</code>;
case ApiMessageEntityTypes.Email: case ApiMessageEntityTypes.Email:
return ( return (
<a <a
@ -280,21 +356,21 @@ function processEntity(
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Italic: case ApiMessageEntityTypes.Italic:
return <em>{renderMessagePart(renderedContent)}</em>; return <em>{renderNestedMessagePart()}</em>;
case ApiMessageEntityTypes.MentionName: case ApiMessageEntityTypes.MentionName:
return ( return (
<MentionLink userId={entity.userId}> <MentionLink userId={entity.userId}>
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</MentionLink> </MentionLink>
); );
case ApiMessageEntityTypes.Mention: case ApiMessageEntityTypes.Mention:
return ( return (
<MentionLink username={entityText}> <MentionLink username={entityText}>
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</MentionLink> </MentionLink>
); );
case ApiMessageEntityTypes.Phone: case ApiMessageEntityTypes.Phone:
@ -304,13 +380,13 @@ function processEntity(
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Pre: case ApiMessageEntityTypes.Pre:
return <pre className="text-entity-pre">{renderMessagePart(renderedContent)}</pre>; return <pre className="text-entity-pre">{renderNestedMessagePart()}</pre>;
case ApiMessageEntityTypes.Strike: case ApiMessageEntityTypes.Strike:
return <del>{renderMessagePart(renderedContent)}</del>; return <del>{renderNestedMessagePart()}</del>;
case ApiMessageEntityTypes.TextUrl: case ApiMessageEntityTypes.TextUrl:
case ApiMessageEntityTypes.Url: case ApiMessageEntityTypes.Url:
return ( return (
@ -318,13 +394,15 @@ function processEntity(
url={getLinkUrl(entityText, entity)} url={getLinkUrl(entityText, entity)}
text={entityText} text={entityText}
> >
{renderMessagePart(renderedContent)} {renderNestedMessagePart()}
</SafeLink> </SafeLink>
); );
case ApiMessageEntityTypes.Underline: case ApiMessageEntityTypes.Underline:
return <ins>{renderMessagePart(renderedContent)}</ins>; return <ins>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Spoiler:
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
default: default:
return renderMessagePart(renderedContent); return renderNestedMessagePart();
} }
} }
@ -333,12 +411,13 @@ function renderMessagePart(
highlight?: string, highlight?: string,
shouldRenderHqEmoji?: boolean, shouldRenderHqEmoji?: boolean,
shouldRenderAsHtml?: boolean, shouldRenderAsHtml?: boolean,
isSimple?: boolean,
) { ) {
if (Array.isArray(content)) { if (Array.isArray(content)) {
const result: TextPart[] = []; const result: TextPart[] = [];
content.forEach((c) => { content.forEach((c) => {
result.push(...renderMessagePart(c, highlight, shouldRenderHqEmoji, shouldRenderAsHtml)); result.push(...renderMessagePart(c, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple));
}); });
return result; return result;
@ -350,10 +429,15 @@ function renderMessagePart(
const emojiFilter = shouldRenderHqEmoji ? 'hq_emoji' : 'emoji'; const emojiFilter = shouldRenderHqEmoji ? 'hq_emoji' : 'emoji';
const filters: TextFilter[] = [emojiFilter];
if (!isSimple) {
filters.push('br');
}
if (highlight) { if (highlight) {
return renderText(content, [emojiFilter, 'br', 'highlight'], { highlight }); return renderText(content, filters.concat('highlight'), { highlight });
} else { } else {
return renderText(content, [emojiFilter, 'br']); return renderText(content, filters);
} }
} }

View File

@ -11,16 +11,17 @@ import MentionLink from '../../middle/message/MentionLink';
import SafeLink from '../SafeLink'; import SafeLink from '../SafeLink';
type TextPart = string | Element; type TextPart = string | Element;
export type TextFilter = (
'escape_html' | 'hq_emoji' | 'emoji' | 'emoji_html' | 'br' | 'br_html' | 'highlight' | 'links' |
'simple_markdown' | 'simple_markdown_html'
);
const RE_LETTER_OR_DIGIT = /^[\d\wа-яё]$/i; const RE_LETTER_OR_DIGIT = /^[\d\wа-яё]$/i;
const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g; const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
export default function renderText( export default function renderText(
part: TextPart, part: TextPart,
filters: Array<( filters: Array<TextFilter> = ['emoji'],
'escape_html' | 'hq_emoji' | 'emoji' | 'emoji_html' | 'br' | 'br_html' | 'highlight' | 'links' |
'simple_markdown' | 'simple_markdown_html'
)> = ['emoji'],
params?: { highlight: string | undefined }, params?: { highlight: string | undefined },
): TextPart[] { ): TextPart[] {
if (typeof part !== 'string') { if (typeof part !== 'string') {

View File

@ -0,0 +1,31 @@
.Spoiler {
transition: color 250ms ease;
&:not(.is-revealed) {
cursor: pointer;
color: transparent;
background-image: url('../../../assets/spoiler-dots-black.png');
background-size: auto 100%;
border-radius: 0.5rem;
&.animate {
animation: pulse-opacity-light 1.75s linear infinite;
}
}
html.theme-dark &:not(.is-revealed), html.theme-light .ListItem.selected &:not(.is-revealed) {
background-image: url('../../../assets/spoiler-dots-white.png');
}
}
@keyframes pulse-opacity-light {
25% {
opacity: 1;
}
50% {
opacity: 0.25;
}
75% {
opacity: 1;
}
}

View File

@ -0,0 +1,66 @@
import React, {
FC, memo, useCallback, useEffect,
} from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import './Spoiler.scss';
type OwnProps = {
children?: React.ReactNode;
messageId?: number;
isInactive?: boolean;
};
const spoilersByMessageId: Map<number, VoidFunction[]> = new Map();
const Spoiler: FC<OwnProps> = ({
children,
messageId,
isInactive,
}) => {
const [isRevealed, reveal] = useFlag();
const handleClick = useCallback(() => {
if (!messageId) return;
spoilersByMessageId.get(messageId)?.forEach((_reveal) => _reveal());
}, [messageId]);
useEffect(() => {
if (isRevealed && messageId) {
spoilersByMessageId.delete(messageId);
return undefined;
}
if (!messageId) {
return undefined;
}
if (spoilersByMessageId.has(messageId)) {
spoilersByMessageId.get(messageId)!.push(reveal);
} else {
spoilersByMessageId.set(messageId, [reveal]);
}
return () => {
spoilersByMessageId.delete(messageId);
};
}, [handleClick, isRevealed, messageId, reveal]);
return (
<span
className={buildClassName(
'Spoiler',
isRevealed && 'is-revealed',
!isInactive && 'animate',
)}
onClick={!isInactive && !isRevealed ? handleClick : undefined}
>
{children}
</span>
);
};
export default memo(Spoiler);

View File

@ -20,7 +20,6 @@ import {
getMessageSenderName, getMessageSenderName,
isChatChannel, isChatChannel,
getMessageMediaHash, getMessageMediaHash,
getMessageSummaryText,
getMessageMediaThumbDataUri, getMessageMediaThumbDataUri,
getMessageVideo, getMessageVideo,
getMessageSticker, getMessageSticker,
@ -40,6 +39,7 @@ import useChatContextActions from '../../../hooks/useChatContextActions';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import { ChatAnimationTypes } from './hooks'; import { ChatAnimationTypes } from './hooks';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import VerifiedIcon from '../../common/VerifiedIcon'; import VerifiedIcon from '../../common/VerifiedIcon';
@ -241,15 +241,14 @@ const Chat: FC<OwnProps & StateProps> = ({
return ( return (
<p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}> <p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}>
{renderText(renderActionMessageText( {renderActionMessageText(
lang, lang,
lastMessage, lastMessage,
actionOrigin, actionOrigin,
actionTargetUsers, actionTargetUsers,
actionTargetMessage, actionTargetMessage,
actionTargetChatId, actionTargetChatId,
{ asPlain: true }, )}
) as string)}
</p> </p>
); );
} }
@ -264,7 +263,7 @@ const Chat: FC<OwnProps & StateProps> = ({
<span className="colon">:</span> <span className="colon">:</span>
</> </>
)} )}
{renderMessageSummary(lang, lastMessage!, mediaBlobUrl || mediaThumbnail, isRoundVideo)} {renderSummary(lang, lastMessage!, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</p> </p>
); );
} }
@ -333,16 +332,16 @@ const Chat: FC<OwnProps & StateProps> = ({
); );
}; };
function renderMessageSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) { function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
if (!blobUrl) { if (!blobUrl) {
return renderText(getMessageSummaryText(lang, message)); return renderMessageSummary(lang, message);
} }
return ( return (
<span className="media-preview"> <span className="media-preview">
<img src={blobUrl} alt="" className={isRoundVideo ? 'round' : undefined} /> <img src={blobUrl} alt="" className={isRoundVideo ? 'round' : undefined} />
{getMessageVideo(message) && <i className="icon-play" />} {getMessageVideo(message) && <i className="icon-play" />}
{renderText(getMessageSummaryText(lang, message, true))} {renderMessageSummary(lang, message, true)}
</span> </span>
); );
} }

View File

@ -12,7 +12,6 @@ import {
getChatTitle, getChatTitle,
getPrivateChatUserId, getPrivateChatUserId,
getMessageMediaHash, getMessageMediaHash,
getMessageSummaryText,
getMessageMediaThumbDataUri, getMessageMediaThumbDataUri,
getMessageVideo, getMessageVideo,
getMessageRoundVideo, getMessageRoundVideo,
@ -23,6 +22,7 @@ import useMedia from '../../../hooks/useMedia';
import { formatPastTimeShort } from '../../../util/dateFormat'; import { formatPastTimeShort } from '../../../util/dateFormat';
import useLang, { LangFn } from '../../../hooks/useLang'; import useLang, { LangFn } from '../../../hooks/useLang';
import useSelectWithEnter from '../../../hooks/useSelectWithEnter'; import useSelectWithEnter from '../../../hooks/useSelectWithEnter';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import VerifiedIcon from '../../common/VerifiedIcon'; import VerifiedIcon from '../../common/VerifiedIcon';
@ -98,7 +98,7 @@ const ChatMessage: FC<OwnProps & StateProps> = ({
</div> </div>
<div className="subtitle"> <div className="subtitle">
<div className="message" dir="auto"> <div className="message" dir="auto">
{renderMessageSummary(lang, message, mediaBlobUrl || mediaThumbnail, searchQuery, isRoundVideo)} {renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, searchQuery, isRoundVideo)}
</div> </div>
</div> </div>
</div> </div>
@ -106,18 +106,18 @@ const ChatMessage: FC<OwnProps & StateProps> = ({
); );
}; };
function renderMessageSummary( function renderSummary(
lang: LangFn, message: ApiMessage, blobUrl?: string, searchQuery?: string, isRoundVideo?: boolean, lang: LangFn, message: ApiMessage, blobUrl?: string, searchQuery?: string, isRoundVideo?: boolean,
) { ) {
if (!blobUrl) { if (!blobUrl) {
return renderText(getMessageSummaryText(lang, message)); return renderMessageSummary(lang, message, undefined, searchQuery);
} }
return ( return (
<span className="media-preview"> <span className="media-preview">
<img src={blobUrl} alt="" className={isRoundVideo ? 'round' : undefined} /> <img src={blobUrl} alt="" className={isRoundVideo ? 'round' : undefined} />
{getMessageVideo(message) && <i className="icon-play" />} {getMessageVideo(message) && <i className="icon-play" />}
{renderText(getMessageSummaryText(lang, message, true), ['emoji', 'highlight'], { highlight: searchQuery })} {renderMessageSummary(lang, message, true, searchQuery)}
</span> </span>
); );
} }

View File

@ -6,10 +6,10 @@ import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { ApiChat, ApiMessage } from '../../../api/types'; import { ApiChat, ApiMessage } from '../../../api/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { getMessageSummaryText } from '../../../modules/helpers';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
@ -76,7 +76,7 @@ const ChatMessageResults: FC<OwnProps & StateProps> = ({
}, [foundIds, globalMessagesByChatId]); }, [foundIds, globalMessagesByChatId]);
function renderFoundMessage(message: ApiMessage) { function renderFoundMessage(message: ApiMessage) {
const text = getMessageSummaryText(lang, message); const text = renderMessageSummary(lang, message);
const chat = chatsById[message.chatId]; const chat = chatsById[message.chatId];
if (!text || !chat) { if (!text || !chat) {

View File

@ -8,10 +8,14 @@ import { LoadMoreDirection } from '../../../types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
import { getMessageSummaryText, sortChatIds, filterUsersByName } from '../../../modules/helpers'; import {
sortChatIds,
filterUsersByName,
} from '../../../modules/helpers';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import LeftSearchResultChat from './LeftSearchResultChat'; import LeftSearchResultChat from './LeftSearchResultChat';
@ -154,7 +158,7 @@ const ChatResults: FC<OwnProps & StateProps> = ({
}, [shouldShowMoreGlobal]); }, [shouldShowMoreGlobal]);
function renderFoundMessage(message: ApiMessage) { function renderFoundMessage(message: ApiMessage) {
const text = getMessageSummaryText(lang, message); const text = renderMessageSummary(lang, message);
const chat = chatsById[message.chatId]; const chat = chatsById[message.chatId];
if (!text || !chat) { if (!text || !chat) {

View File

@ -14,7 +14,6 @@ import {
} from '../../modules/selectors'; } from '../../modules/selectors';
import { isChatChannel } from '../../modules/helpers'; import { isChatChannel } from '../../modules/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import { renderActionMessageText } from '../common/helpers/renderActionMessageText'; import { renderActionMessageText } from '../common/helpers/renderActionMessageText';
import useEnsureMessage from '../../hooks/useEnsureMessage'; import useEnsureMessage from '../../hooks/useEnsureMessage';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
@ -96,7 +95,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
targetUsers, targetUsers,
targetMessage, targetMessage,
targetChatId, targetChatId,
isEmbedded ? { isEmbedded: true, asPlain: true } : undefined, isEmbedded ? { isEmbedded: true } : undefined,
); );
const { const {
isContextMenuOpen, contextMenuPosition, isContextMenuOpen, contextMenuPosition,
@ -111,7 +110,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
}; };
if (isEmbedded) { if (isEmbedded) {
return <span className="embedded-action-message">{renderText(content as string)}</span>; return <span className="embedded-action-message">{content}</span>;
} }
const className = buildClassName( const className = buildClassName(

View File

@ -3,8 +3,7 @@ import React, { FC, memo, useCallback } from '../../lib/teact/teact';
import { ApiMessage } from '../../api/types'; import { ApiMessage } from '../../api/types';
import { getPictogramDimensions } from '../common/helpers/mediaDimensions'; import { getPictogramDimensions } from '../common/helpers/mediaDimensions';
import { getMessageMediaHash, getMessageSummaryText } from '../../modules/helpers'; import { getMessageMediaHash } from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useWebpThumbnail from '../../hooks/useWebpThumbnail'; import useWebpThumbnail from '../../hooks/useWebpThumbnail';
@ -14,6 +13,7 @@ import RippleEffect from '../ui/RippleEffect';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import { renderMessageSummary } from '../common/helpers/renderMessageText';
import PinnedMessageNavigation from './PinnedMessageNavigation'; import PinnedMessageNavigation from './PinnedMessageNavigation';
@ -35,7 +35,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const mediaThumbnail = useWebpThumbnail(message); const mediaThumbnail = useWebpThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram')); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const text = getMessageSummaryText(lang, message, Boolean(mediaThumbnail)); const text = renderMessageSummary(lang, message, Boolean(mediaThumbnail));
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
const handleUnpinMessage = useCallback(() => { const handleUnpinMessage = useCallback(() => {
@ -89,7 +89,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
<div className="title" dir="auto"> <div className="title" dir="auto">
{customTitle || `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} {customTitle || `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`}
</div> </div>
<p dir="auto">{renderText(text)}</p> <p dir="auto">{text}</p>
</div> </div>
<RippleEffect /> <RippleEffect />

View File

@ -12,7 +12,6 @@ import {
selectCurrentTextSearch, selectCurrentTextSearch,
} from '../../modules/selectors'; } from '../../modules/selectors';
import { import {
getMessageSummaryText,
getChatTitle, getChatTitle,
getUserFullName, getUserFullName,
isChatChannel, isChatChannel,
@ -23,6 +22,7 @@ import { orderBy } from '../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import useHistoryBack from '../../hooks/useHistoryBack'; import useHistoryBack from '../../hooks/useHistoryBack';
import { renderMessageSummary } from '../common/helpers/renderMessageText';
import InfiniteScroll from '../ui/InfiniteScroll'; import InfiniteScroll from '../ui/InfiniteScroll';
import ListItem from '../ui/ListItem'; import ListItem from '../ui/ListItem';
@ -109,7 +109,7 @@ const RightSearch: FC<OwnProps & StateProps> = ({
message, senderUser, senderChat, onClick, message, senderUser, senderChat, onClick,
}: Result) => { }: Result) => {
const title = senderChat ? getChatTitle(lang, senderChat) : getUserFullName(senderUser); const title = senderChat ? getChatTitle(lang, senderChat) : getUserFullName(senderUser);
const text = getMessageSummaryText(lang, message); const text = renderMessageSummary(lang, message, undefined, query);
return ( return (
<ListItem <ListItem
@ -123,7 +123,7 @@ const RightSearch: FC<OwnProps & StateProps> = ({
<LastMessageMeta message={message} /> <LastMessageMeta message={message} />
</div> </div>
<div className="subtitle" dir="auto"> <div className="subtitle" dir="auto">
{renderText(text, ['emoji', 'highlight'], { highlight: query })} {text}
</div> </div>
</div> </div>
</ListItem> </ListItem>

View File

@ -1,6 +1,7 @@
export * from './users'; export * from './users';
export * from './chats'; export * from './chats';
export * from './messages'; export * from './messages';
export * from './messageSummary';
export * from './messageMedia'; export * from './messageMedia';
export * from './localSearch'; export * from './localSearch';
export * from './payments'; export * from './payments';

View File

@ -0,0 +1,141 @@
import { LangFn } from '../../hooks/useLang';
import { ApiMessage, ApiMessageEntityTypes } from '../../api/types';
import type { TextPart } from '../../components/common/helpers/renderMessageText';
import { CONTENT_NOT_SUPPORTED } from '../../config';
import { getMessageText } from './messages';
const SPOILER_CHARS = ['⠺', '⠵', '⠞', '⠟'];
export const TRUNCATED_SUMMARY_LENGTH = 80;
export function getMessageSummaryText(
lang: LangFn,
message: ApiMessage,
noEmoji = false,
truncateLength = TRUNCATED_SUMMARY_LENGTH,
) {
const emoji = !noEmoji && getMessageSummaryEmoji(message);
const emojiWithSpace = emoji ? `${emoji} ` : '';
let text = getMessageText(message);
if (text) {
const { entities } = message.content.text || {};
if (entities?.length) {
text = entities.reduce((accText, { type, offset, length }) => {
if (type !== ApiMessageEntityTypes.Spoiler) {
return accText;
}
const spoiler = generateBrailleSpoiler(length);
return `${accText.substr(0, offset)}${spoiler}${accText.substr(offset + length, accText.length)}`;
}, text);
}
text = text.substr(0, truncateLength);
}
const description = getMessageSummaryDescription(lang, message, text);
return `${emojiWithSpace}${description}`;
}
export function getMessageSummaryEmoji(message: ApiMessage) {
const {
photo, video, audio, voice, document, sticker, poll,
} = message.content;
if (message.groupedId || photo) {
return '🖼';
}
if (video) {
return '📹';
}
if (sticker) {
return sticker.emoji;
}
if (audio) {
return '🎧';
}
if (voice) {
return '🎤';
}
if (document) {
return '📎';
}
if (poll) {
return '📊';
}
return undefined;
}
export function getMessageSummaryDescription(lang: LangFn, message: ApiMessage, truncatedText?: string | TextPart[]) {
const {
text, photo, video, audio, voice, document, sticker, contact, poll, invoice,
} = message.content;
if (message.groupedId) {
return truncatedText || lang('lng_in_dlg_album');
}
if (photo) {
return truncatedText || lang('AttachPhoto');
}
if (video) {
return truncatedText || lang(video.isGif ? 'AttachGif' : 'AttachVideo');
}
if (sticker) {
return lang('AttachSticker').trim();
}
if (audio) {
return getMessageAudioCaption(message) || lang('AttachMusic');
}
if (voice) {
return truncatedText || lang('AttachAudio');
}
if (document) {
return truncatedText || document.fileName;
}
if (contact) {
return lang('AttachContact');
}
if (poll) {
return poll.summary.question;
}
if (invoice) {
return 'Invoice';
}
if (text) {
return truncatedText;
}
return CONTENT_NOT_SUPPORTED;
}
export function generateBrailleSpoiler(length: number) {
return new Array(length)
.fill(undefined)
.map(() => SPOILER_CHARS[Math.floor(Math.random() * SPOILER_CHARS.length)])
.join('');
}
function getMessageAudioCaption(message: ApiMessage) {
const { audio, text } = message.content;
return (audio && [audio.title, audio.performer].filter(Boolean).join(' — ')) || (text?.text);
}

View File

@ -4,18 +4,17 @@ import {
import { LangFn } from '../../hooks/useLang'; import { LangFn } from '../../hooks/useLang';
import { import {
LOCAL_MESSAGE_ID_BASE,
SERVICE_NOTIFICATIONS_USER_ID,
RE_LINK_TEMPLATE,
CONTENT_NOT_SUPPORTED, CONTENT_NOT_SUPPORTED,
LOCAL_MESSAGE_ID_BASE,
RE_LINK_TEMPLATE,
SERVICE_NOTIFICATIONS_USER_ID,
} from '../../config'; } from '../../config';
import { getUserFullName } from './users'; import { getUserFullName } from './users';
import { isWebpSupported, IS_OPUS_SUPPORTED } from '../../util/environment'; import { IS_OPUS_SUPPORTED, isWebpSupported } from '../../util/environment';
import { getChatTitle, isUserId } from './chats'; import { getChatTitle, isUserId } from './chats';
import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString'; import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
const TRUNCATED_SUMMARY_LENGTH = 80;
export type MessageKey = `msg${string}-${number}`; export type MessageKey = `msg${string}-${number}`;
@ -39,60 +38,6 @@ export function getMessageOriginalId(message: ApiMessage) {
return message.previousLocalId || message.id; return message.previousLocalId || message.id;
} }
export function getMessageSummaryText(lang: LangFn, message: ApiMessage, noEmoji = false) {
const {
text, photo, video, audio, voice, document, sticker, contact, poll, invoice,
} = message.content;
const truncatedText = text && text.text.substr(0, TRUNCATED_SUMMARY_LENGTH);
if (message.groupedId) {
return `${noEmoji ? '' : '🖼 '}${truncatedText || lang('lng_in_dlg_album')}`;
}
if (photo) {
return `${noEmoji ? '' : '🖼 '}${truncatedText || lang('AttachPhoto')}`;
}
if (video) {
return `${noEmoji ? '' : '📹 '}${truncatedText || lang(video.isGif ? 'AttachGif' : 'AttachVideo')}`;
}
if (sticker) {
return `${sticker.emoji || ''} ${lang('AttachSticker')}`.trim();
}
if (audio) {
return `${noEmoji ? '' : '🎧 '}${getMessageAudioCaption(message) || lang('AttachMusic')}`;
}
if (voice) {
return `${noEmoji ? '' : '🎤 '}${truncatedText || lang('AttachAudio')}`;
}
if (document) {
return `${noEmoji ? '' : '📎 '}${truncatedText || document.fileName}`;
}
if (contact) {
return lang('AttachContact');
}
if (poll) {
return `${noEmoji ? '' : '📊 '}${poll.summary.question}`;
}
if (invoice) {
return 'Invoice';
}
if (text) {
return truncatedText;
}
return CONTENT_NOT_SUPPORTED;
}
export function getMessageText(message: ApiMessage) { export function getMessageText(message: ApiMessage) {
const { const {
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice,
@ -230,12 +175,6 @@ export function isHistoryClearMessage(message: ApiMessage) {
return message.content.action && message.content.action.type === 'historyClear'; return message.content.action && message.content.action.type === 'historyClear';
} }
export function getMessageAudioCaption(message: ApiMessage) {
const { audio, text } = message.content;
return (audio && [audio.title, audio.performer].filter(Boolean).join(' — ')) || (text?.text);
}
export function getMessageContentFilename(message: ApiMessage) { export function getMessageContentFilename(message: ApiMessage) {
const { content } = message; const { content } = message;