import type { ElementRef } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { TextPart, ThreadId } from '../../../types'; import type { TextFilter } from './renderText'; import { ApiMessageEntityTypes } from '../../../api/types'; import { ensureProtocol } from '../../../util/browser/url'; import buildClassName from '../../../util/buildClassName'; import { copyTextToClipboard } from '../../../util/clipboard'; import { buildFormattedDateHtml } from '../../../util/dates/formattedDate'; import { escapeHtmlAttribute } from '../../middle/composer/helpers/cleanHtml'; 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 FormattedDate from '../FormattedDate'; 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, asPreview, isProtected, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, noCustomEmojiPlayback, isInSelectMode, chatId, messageId, threadId, maxTimestamp, }: { text: string; entities?: ApiMessageEntity[]; highlight?: string; emojiSize?: number; shouldRenderAsHtml?: boolean; containerId?: string; asPreview?: boolean; isProtected?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; withTranslucentThumbs?: boolean; sharedCanvasRef?: ElementRef; sharedCanvasHqRef?: ElementRef; cacheBuster?: string; forcePlayback?: boolean; noCustomEmojiPlayback?: boolean; isInSelectMode?: boolean; chatId?: string; messageId?: number; threadId?: ThreadId; maxTimestamp?: number; }) { if (!entities?.length) { return renderMessagePart({ content: text, highlight, emojiSize, shouldRenderAsHtml, asPreview, }); } 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, emojiSize, shouldRenderAsHtml, asPreview, })); } } 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, containerId, asPreview, isProtected, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, emojiSize, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, noCustomEmojiPlayback, isInSelectMode, chatId, messageId, threadId, maxTimestamp, }); 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, emojiSize, shouldRenderAsHtml, asPreview, })); } } 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, }) as string[]; if (Array.isArray(result)) { return result.join(''); } return result; } function renderMessagePart({ content, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, asPreview, }: { content: TextPart | TextPart[]; highlight?: string; focusedQuote?: string; emojiSize?: number; shouldRenderAsHtml?: boolean; asPreview?: boolean; }) { if (Array.isArray(content)) { const result: TextPart[] = []; content.forEach((c) => { result.push(...renderMessagePart({ content: c, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, asPreview, })); }); 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 (!asPreview) { filters.push('br'); } if (highlight) { filters.push('highlight'); params.highlight = highlight; } return renderText(content, filters, params); } export function insertTextEntities(entities: ApiMessageEntity[], newEntities: ApiMessageEntity[]) { return newEntities.reduce((acc, newEntity) => { return insertTextEntity(acc, newEntity); }, entities); } export function insertTextEntity(entities: ApiMessageEntity[], newEntity: ApiMessageEntity) { const resultEntities: ApiMessageEntity[] = []; const newEntityStart = newEntity.offset; const newEntityEnd = newEntity.offset + newEntity.length; for (const existingEntity of entities) { const existingEntityStart = existingEntity.offset; const existingEntityEnd = existingEntity.offset + existingEntity.length; // Push as is if edges do not overlap if (existingEntityEnd <= newEntityStart || existingEntityStart > newEntityEnd || (existingEntityStart > newEntityStart && existingEntityEnd < newEntityEnd) || (existingEntityStart === newEntityStart && existingEntityEnd === newEntityEnd)) { resultEntities.push(existingEntity); continue; } // If start edge overlaps if (existingEntityStart < newEntityStart && existingEntityEnd > newEntityStart) { // Split entity in two resultEntities.push({ ...existingEntity, length: newEntityStart - existingEntityStart, }); resultEntities.push({ ...existingEntity, offset: newEntityStart, length: existingEntityEnd - newEntityStart, }); } // If end edge overlaps if (existingEntityStart < newEntityEnd && existingEntityEnd > newEntityEnd) { // Split entity in two resultEntities.push({ ...existingEntity, offset: newEntityEnd, length: existingEntityEnd - newEntityStart - newEntity.length, }); resultEntities.push({ ...existingEntity, length: newEntityEnd - existingEntityStart, }); } } resultEntities.push(newEntity); // Sort entities by offset, longer entities first return resultEntities.sort((a, b) => a.offset - b.offset || b.length - a.length); } // Organize entities in a tree-like structure to better represent how the text will be displayed function organizeEntities(entities: ApiMessageEntity[]) { const organizedEntityIndexes = 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, containerId, asPreview, isProtected, observeIntersectionForLoading, observeIntersectionForPlaying, withTranslucentThumbs, emojiSize, sharedCanvasRef, sharedCanvasHqRef, cacheBuster, forcePlayback, noCustomEmojiPlayback, isInSelectMode, chatId, messageId, threadId, maxTimestamp, }: { entity: ApiMessageEntity; entityContent: TextPart; nestedEntityContent: TextPart[]; highlight?: string; focusedQuote?: string; containerId?: string; asPreview?: boolean; isProtected?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; withTranslucentThumbs?: boolean; emojiSize?: number; sharedCanvasRef?: ElementRef; sharedCanvasHqRef?: ElementRef; cacheBuster?: string; forcePlayback?: boolean; noCustomEmojiPlayback?: boolean; isInSelectMode?: boolean; chatId?: string; messageId?: number; threadId?: ThreadId; maxTimestamp?: number; }) { const entityText = typeof entityContent === 'string' && entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; function renderNestedMessagePart() { return renderMessagePart({ content: renderedContent, highlight, emojiSize, asPreview, }); } if (!entityText) { return renderNestedMessagePart(); } if (asPreview) { const text = renderNestedMessagePart(); if (entity.type === ApiMessageEntityTypes.Spoiler) { return {text}; } if (entity.type === ApiMessageEntityTypes.CustomEmoji) { return ( ); } if (entity.type === ApiMessageEntityTypes.FormattedDate && entity.date) { // Old entities can have missing fields return {text}; } return text; } switch (entity.type) { case ApiMessageEntityTypes.Bold: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.Blockquote: return (
{renderNestedMessagePart()}
); case ApiMessageEntityTypes.BotCommand: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Hashtag: { const [tag, username] = entityContent.split('@'); return ( handleHashtagClick(tag, username)} className="text-entity-link" dir="auto" data-entity-type={entity.type} > {renderNestedMessagePart()} ); } case ApiMessageEntityTypes.Cashtag: { const [tag, username] = entityContent.split('@'); return ( handleHashtagClick(tag, username)} className="text-entity-link" dir="auto" data-entity-type={entity.type} > {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.Timestamp: if (!chatId || !messageId || !maxTimestamp || entity.timestamp > maxTimestamp) { return renderNestedMessagePart(); } return ( handleTimecodeClick(chatId, messageId, threadId, entity.timestamp)} className="text-entity-link" dir="auto" data-entity-type={entity.type} > {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Spoiler: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.CustomEmoji: return ( ); case ApiMessageEntityTypes.QuoteFocus: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.FormattedDate: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.DiffInsert: return ( {renderNestedMessagePart()} ); case ApiMessageEntityTypes.DiffReplace: return ( {entity.oldText} {renderNestedMessagePart()} ); case ApiMessageEntityTypes.DiffDelete: return ( {renderNestedMessagePart()} ); 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 as string[]).join('') : (renderText(content, ['escape_html', 'emoji_html', 'br_html']) as string[]).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: // eslint-disable-next-line @stylistic/max-len return `\`\`\`${renderText(entity.language || '', ['escape_html'])[0] as string}
${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}
`; case ApiMessageEntityTypes.FormattedDate: return buildFormattedDateHtml(renderedContent, entity); default: return renderedContent; } } function getLinkUrl(entityContent: string, entity: ApiMessageEntity) { const { type } = entity; return type === ApiMessageEntityTypes.TextUrl && entity.url ? entity.url : entityContent; } function getHtmlLinkUrl(entityContent: string, entity: ApiMessageEntity) { return escapeHtmlAttribute(ensureProtocol(getLinkUrl(entityContent, entity))); } function handleBotCommandClick(e: React.MouseEvent) { getActions().sendBotCommand({ command: e.currentTarget.innerText }); } function handleHashtagClick(hashtag?: string, username?: string) { if (!hashtag) return; if (username) { getActions().openChatByUsername({ username, onChatChanged: { action: 'searchHashtag', payload: { hashtag }, }, }); return; } getActions().searchHashtag({ hashtag }); } function handleCodeClick(e: React.MouseEvent) { copyTextToClipboard(e.currentTarget.innerText); getActions().showNotification({ message: { key: 'TextCopied', }, }); } function handleTimecodeClick( chatId: string, messageId: number, threadId: ThreadId | undefined, timestamp: number, ) { getActions().openMediaFromTimestamp({ chatId, messageId, threadId, timestamp, }); }