import React from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { TextPart } from '../../../types'; import type { TextFilter } from './renderText'; import { ApiMessageEntityTypes } from '../../../api/types'; import buildClassName from '../../../util/buildClassName'; import { copyTextToClipboard } from '../../../util/clipboard'; import { oldTranslate } from '../../../util/oldLangProvider'; import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji'; import renderText from './renderText'; import MentionLink from '../../middle/message/MentionLink'; import Blockquote from '../Blockquote'; import CodeBlock from '../code/CodeBlock'; import CustomEmoji from '../CustomEmoji'; import SafeLink from '../SafeLink'; import Spoiler from '../spoiler/Spoiler'; interface IOrganizedEntity { entity: ApiMessageEntity; organizedIndexes: Set; nestedEntities: IOrganizedEntity[]; } type RenderTextParams = Parameters[2]; const HQ_EMOJI_THRESHOLD = 64; export function renderTextWithEntities({ text, entities, highlight, emojiSize, shouldRenderAsHtml, containerId, isSimple, isProtected, noLineBreaks, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, focusedQuote, isInSelectMode, }: { text: string; entities?: ApiMessageEntity[]; highlight?: string; emojiSize?: number; shouldRenderAsHtml?: boolean; containerId?: string; isSimple?: boolean; isProtected?: boolean; noLineBreaks?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; withTranslucentThumbs?: boolean; sharedCanvasRef?: React.RefObject; sharedCanvasHqRef?: React.RefObject; cacheBuster?: string; forcePlayback?: boolean; focusedQuote?: string; isInSelectMode?: boolean; }) { if (!entities?.length) { return renderMessagePart({ content: text, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, noLineBreaks, }); } const result: TextPart[] = []; let deleteLineBreakAfterPre = false; const organizedEntities = organizeEntities(entities); // Recursive function to render regular and nested entities function renderEntity( textPartStart: number, textPartEnd: number, organizedEntity: IOrganizedEntity, isLastEntity: boolean, ) { const renderResult: TextPart[] = []; const { entity, nestedEntities } = organizedEntity; const { offset, length, type } = entity; // Render text before the entity let textBefore = text.substring(textPartStart, offset); const textBeforeLength = textBefore.length; if (textBefore) { if (deleteLineBreakAfterPre && textBefore.length > 0 && textBefore[0] === '\n') { textBefore = textBefore.substr(1); deleteLineBreakAfterPre = false; } if (textBefore) { renderResult.push(...renderMessagePart({ content: textBefore, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, noLineBreaks, }) as TextPart[]); } } const entityStartIndex = textPartStart + textBeforeLength; const entityEndIndex = entityStartIndex + length; let entityContent: TextPart = text.substring(offset, offset + length); const nestedEntityContent: TextPart[] = []; if (deleteLineBreakAfterPre && entityContent.length > 0 && entityContent[0] === '\n') { entityContent = entityContent.substr(1); deleteLineBreakAfterPre = false; } if (type === ApiMessageEntityTypes.Pre) { deleteLineBreakAfterPre = true; } // Render nested entities, if any if (nestedEntities.length) { let nestedIndex = entityStartIndex; nestedEntities.forEach((nestedEntity, nestedEntityIndex) => { const { renderResult: nestedResult, entityEndIndex: nestedEntityEndIndex, } = renderEntity( nestedIndex, entityEndIndex, nestedEntity, nestedEntityIndex === nestedEntities.length - 1, ); nestedEntityContent.push(...nestedResult); nestedIndex = nestedEntityEndIndex; }); } // Render the entity itself const newEntity = shouldRenderAsHtml ? processEntityAsHtml(entity, entityContent, nestedEntityContent) : processEntity({ entity, entityContent, nestedEntityContent, highlight, focusedQuote, containerId, isSimple, noLineBreaks, isProtected, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, emojiSize, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, isInSelectMode, }); if (Array.isArray(newEntity)) { renderResult.push(...newEntity); } else { renderResult.push(newEntity); } // Render text after the entity, if it is the last entity in the text, // or the last nested entity inside of another entity if (isLastEntity && entityEndIndex < textPartEnd) { let textAfter = text.substring(entityEndIndex, textPartEnd); if (deleteLineBreakAfterPre && textAfter.length > 0 && textAfter[0] === '\n') { textAfter = textAfter.substring(1); } if (textAfter) { renderResult.push(...renderMessagePart({ content: textAfter, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, noLineBreaks, }) as TextPart[]); } } return { renderResult, entityEndIndex, }; } // Process highest-level entities let index = 0; organizedEntities.forEach((entity, arrayIndex) => { const { renderResult, entityEndIndex } = renderEntity( index, text.length, entity, arrayIndex === organizedEntities.length - 1, ); result.push(...renderResult); index = entityEndIndex; }); return result; } export function getTextWithEntitiesAsHtml(formattedText?: ApiFormattedText) { const { text, entities } = formattedText || {}; if (!text) { return ''; } const result = renderTextWithEntities({ text, entities, shouldRenderAsHtml: true, }); if (Array.isArray(result)) { return result.join(''); } return result; } function renderMessagePart({ content, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, noLineBreaks, } : { content: TextPart | TextPart[]; highlight?: string; focusedQuote?: string; emojiSize?: number; shouldRenderAsHtml?: boolean; isSimple?: boolean; noLineBreaks?: boolean; }) { if (Array.isArray(content)) { const result: TextPart[] = []; content.forEach((c) => { result.push(...renderMessagePart({ content: c, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, noLineBreaks, })); }); return result; } if (shouldRenderAsHtml) { return renderText(content, ['escape_html', 'emoji_html', 'br_html']); } const emojiFilter = emojiSize && emojiSize > HQ_EMOJI_THRESHOLD ? 'hq_emoji' : 'emoji'; const filters: TextFilter[] = [emojiFilter]; const params: RenderTextParams = {}; if (!isSimple && !noLineBreaks) { filters.push('br'); } if (highlight) { filters.push('highlight'); params.highlight = highlight; } if (focusedQuote) { filters.push('quote'); params.quote = focusedQuote; } return renderText(content, filters, params); } // Organize entities in a tree-like structure to better represent how the text will be displayed function organizeEntities(entities: ApiMessageEntity[]) { const organizedEntityIndexes: Set = new Set(); const organizedEntities: IOrganizedEntity[] = []; entities.forEach((entity, index) => { if (organizedEntityIndexes.has(index)) { return; } const organizedEntity = organizeEntity(entity, index, entities, organizedEntityIndexes); if (organizedEntity) { organizedEntity.organizedIndexes.forEach((organizedIndex) => { organizedEntityIndexes.add(organizedIndex); }); organizedEntities.push(organizedEntity); } }); return organizedEntities; } function organizeEntity( entity: ApiMessageEntity, index: number, entities: ApiMessageEntity[], organizedEntityIndexes: Set, ): IOrganizedEntity | undefined { const { offset, length } = entity; const organizedIndexes = new Set([index]); if (organizedEntityIndexes.has(index)) { return undefined; } // Determine any nested entities inside current entity const nestedEntities: IOrganizedEntity[] = []; const parsedNestedEntities = entities .filter((e, i) => i > index && e.offset >= offset && e.offset < offset + length) .map((e) => organizeEntity(e, entities.indexOf(e), entities, organizedEntityIndexes)) .filter(Boolean); parsedNestedEntities.forEach((parsedEntity) => { let isChanged = false; parsedEntity.organizedIndexes.forEach((organizedIndex) => { if (!isChanged && !organizedIndexes.has(organizedIndex)) { isChanged = true; } organizedIndexes.add(organizedIndex); }); if (isChanged) { nestedEntities.push(parsedEntity); } }); return { entity, organizedIndexes, nestedEntities, }; } function processEntity({ entity, entityContent, nestedEntityContent, highlight, focusedQuote, containerId, isSimple, noLineBreaks, isProtected, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, emojiSize, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, isInSelectMode, } : { entity: ApiMessageEntity; entityContent: TextPart; nestedEntityContent: TextPart[]; highlight?: string; focusedQuote?: string; containerId?: string; isSimple?: boolean; noLineBreaks?: boolean; isProtected?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; withTranslucentThumbs?: boolean; emojiSize?: number; sharedCanvasRef?: React.RefObject; sharedCanvasHqRef?: React.RefObject; cacheBuster?: string; forcePlayback?: boolean; isInSelectMode?: boolean; }) { const entityText = typeof entityContent === 'string' && entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; function renderNestedMessagePart() { return renderMessagePart({ content: renderedContent, highlight, focusedQuote, emojiSize, isSimple, noLineBreaks, }); } if (!entityText) { return renderNestedMessagePart(); } if (isSimple) { const text = renderNestedMessagePart(); if (entity.type === ApiMessageEntityTypes.Spoiler) { return {text}; } if (entity.type === ApiMessageEntityTypes.CustomEmoji) { return ( ); } return text; } switch (entity.type) { case ApiMessageEntityTypes.Bold: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.Blockquote: return (
{renderNestedMessagePart()}
); case ApiMessageEntityTypes.BotCommand: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Hashtag: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Cashtag: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Code: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Email: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Italic: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.MentionName: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Mention: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Phone: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Pre: return ; case ApiMessageEntityTypes.Strike: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.TextUrl: case ApiMessageEntityTypes.Url: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Underline: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.Spoiler: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.CustomEmoji: return ( ); default: return renderNestedMessagePart(); } } function processEntityAsHtml( entity: ApiMessageEntity, entityContent: TextPart, nestedEntityContent: TextPart[], ) { const rawEntityText = typeof entityContent === 'string' ? entityContent : undefined; // Prevent adding newlines when editing const content = entity.type === ApiMessageEntityTypes.Pre ? (entityContent as string).trimEnd() : entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent.join('') : renderText(content, ['escape_html', 'emoji_html', 'br_html']).join(''); if (!rawEntityText) { return renderedContent; } switch (entity.type) { case ApiMessageEntityTypes.Bold: return `${renderedContent}`; case ApiMessageEntityTypes.Italic: return `${renderedContent}`; case ApiMessageEntityTypes.Underline: return `${renderedContent}`; case ApiMessageEntityTypes.Code: return `${renderedContent}`; case ApiMessageEntityTypes.Pre: return `\`\`\`${renderText(entity.language || '', ['escape_html'])}
${renderedContent}
\`\`\`
`; case ApiMessageEntityTypes.Strike: return `${renderedContent}`; case ApiMessageEntityTypes.MentionName: return `${renderedContent}`; case ApiMessageEntityTypes.Url: case ApiMessageEntityTypes.TextUrl: return `${renderedContent}`; case ApiMessageEntityTypes.Spoiler: return `${renderedContent}`; case ApiMessageEntityTypes.CustomEmoji: return buildCustomEmojiHtmlFromEntity(rawEntityText, entity); case ApiMessageEntityTypes.Blockquote: return `
${renderedContent}
`; default: return renderedContent; } } function getLinkUrl(entityContent: string, entity: ApiMessageEntity) { const { type } = entity; return type === ApiMessageEntityTypes.TextUrl && entity.url ? entity.url : entityContent; } function handleBotCommandClick(e: React.MouseEvent) { getActions().sendBotCommand({ command: e.currentTarget.innerText }); } function handleHashtagClick(e: React.MouseEvent) { getActions().searchHashtag({ hashtag: e.currentTarget.innerText }); } function handleCodeClick(e: React.MouseEvent) { copyTextToClipboard(e.currentTarget.innerText); getActions().showNotification({ message: oldTranslate('TextCopied'), }); }