TelegramPWA/src/components/common/helpers/renderTextWithEntities.tsx
2022-05-20 17:54:31 +02:00

487 lines
14 KiB
TypeScript

import { MouseEvent } from 'react';
import React from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import { TextPart } from '../../../types';
import { ApiFormattedText, ApiMessageEntity, ApiMessageEntityTypes } from '../../../api/types';
import renderText, { TextFilter } from './renderText';
import { copyTextToClipboard } from '../../../util/clipboard';
import { getTranslation } from '../../../util/langProvider';
import MentionLink from '../../middle/message/MentionLink';
import SafeLink from '../SafeLink';
import Spoiler from '../spoiler/Spoiler';
import CodeBlock from '../code/CodeBlock';
import buildClassName from '../../../util/buildClassName';
interface IOrganizedEntity {
entity: ApiMessageEntity;
organizedIndexes: Set<number>;
nestedEntities: IOrganizedEntity[];
}
export function renderTextWithEntities(
text: string,
entities?: ApiMessageEntity[],
highlight?: string,
shouldRenderHqEmoji?: boolean,
shouldRenderAsHtml?: boolean,
messageId?: number,
isSimple?: boolean,
isProtected?: boolean,
) {
if (!entities || !entities.length) {
return renderMessagePart(text, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple);
}
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(
textBefore, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple,
) 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, messageId, isSimple, isProtected);
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(
textAfter, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple,
) 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,
undefined,
undefined,
true,
);
if (Array.isArray(result)) {
return result.join('');
}
return result;
}
function renderMessagePart(
content: TextPart | TextPart[],
highlight?: string,
shouldRenderHqEmoji?: boolean,
shouldRenderAsHtml?: boolean,
isSimple?: boolean,
) {
if (Array.isArray(content)) {
const result: TextPart[] = [];
content.forEach((c) => {
result.push(...renderMessagePart(c, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple));
});
return result;
}
if (shouldRenderAsHtml) {
return renderText(content, ['escape_html', 'emoji_html', 'br_html']);
}
const emojiFilter = shouldRenderHqEmoji ? 'hq_emoji' : 'emoji';
const filters: TextFilter[] = [emojiFilter];
if (!isSimple) {
filters.push('br');
}
if (highlight) {
return renderText(content, filters.concat('highlight'), { highlight });
} else {
return renderText(content, filters);
}
}
// Organize entities in a tree-like structure to better represent how the text will be displayed
function organizeEntities(entities: ApiMessageEntity[]) {
const organizedEntityIndexes: Set<number> = 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<number>,
): 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<IOrganizedEntity>(Boolean as any);
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: ApiMessageEntity,
entityContent: TextPart,
nestedEntityContent: TextPart[],
highlight?: string,
messageId?: number,
isSimple?: boolean,
isProtected?: boolean,
) {
const entityText = typeof entityContent === 'string' && entityContent;
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
function renderNestedMessagePart() {
return renderMessagePart(
renderedContent, highlight, undefined, undefined, isSimple,
);
}
if (!entityText) {
return renderNestedMessagePart();
}
if (isSimple) {
const text = renderNestedMessagePart();
if (entity.type === ApiMessageEntityTypes.Spoiler) {
return <Spoiler>{text}</Spoiler>;
}
return text;
}
switch (entity.type) {
case ApiMessageEntityTypes.Bold:
return <strong>{renderNestedMessagePart()}</strong>;
case ApiMessageEntityTypes.Blockquote:
return <blockquote>{renderNestedMessagePart()}</blockquote>;
case ApiMessageEntityTypes.BotCommand:
return (
<a
onClick={handleBotCommandClick}
className="text-entity-link"
dir="auto"
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Hashtag:
return (
<a
onClick={handleHashtagClick}
className="text-entity-link"
dir="auto"
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Cashtag:
return (
<a
onClick={handleHashtagClick}
className="text-entity-link"
dir="auto"
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Code:
return (
<code
className={buildClassName('text-entity-code', !isProtected && 'clickable')}
onClick={!isProtected ? handleCodeClick : undefined}
role="textbox"
tabIndex={0}
>
{renderNestedMessagePart()}
</code>
);
case ApiMessageEntityTypes.Email:
return (
<a
href={`mailto:${entityText}`}
target="_blank"
rel="noopener noreferrer"
className="text-entity-link"
dir="auto"
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Italic:
return <em>{renderNestedMessagePart()}</em>;
case ApiMessageEntityTypes.MentionName:
return (
<MentionLink userId={entity.userId}>
{renderNestedMessagePart()}
</MentionLink>
);
case ApiMessageEntityTypes.Mention:
return (
<MentionLink username={entityText}>
{renderNestedMessagePart()}
</MentionLink>
);
case ApiMessageEntityTypes.Phone:
return (
<a
href={`tel:${entityText}`}
className="text-entity-link"
dir="auto"
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Pre:
return <CodeBlock text={entityText} language={entity.language} noCopy={isProtected} />;
case ApiMessageEntityTypes.Strike:
return <del>{renderNestedMessagePart()}</del>;
case ApiMessageEntityTypes.TextUrl:
case ApiMessageEntityTypes.Url:
return (
<SafeLink
url={getLinkUrl(entityText, entity)}
text={entityText}
>
{renderNestedMessagePart()}
</SafeLink>
);
case ApiMessageEntityTypes.Underline:
return <ins>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Spoiler:
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
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 `<b>${renderedContent}</b>`;
case ApiMessageEntityTypes.Italic:
return `<i>${renderedContent}</i>`;
case ApiMessageEntityTypes.Underline:
return `<u>${renderedContent}</u>`;
case ApiMessageEntityTypes.Code:
return `<code class="text-entity-code">${renderedContent}</code>`;
case ApiMessageEntityTypes.Pre:
return `\`\`\`${entity.language || ''}<br/>${renderedContent}<br/>\`\`\`<br/>`;
case ApiMessageEntityTypes.Strike:
return `<del>${renderedContent}</del>`;
case ApiMessageEntityTypes.MentionName:
return `<a
class="text-entity-link"
data-entity-type="${ApiMessageEntityTypes.MentionName}"
data-user-id="${entity.userId}"
contenteditable="false"
dir="auto"
>${renderedContent}</a>`;
case ApiMessageEntityTypes.Url:
case ApiMessageEntityTypes.TextUrl:
return `<a
class="text-entity-link"
href=${getLinkUrl(rawEntityText, entity)}
data-entity-type="${entity.type}"
dir="auto"
>${renderedContent}</a>`;
case ApiMessageEntityTypes.Spoiler:
return `<span
class="spoiler"
data-entity-type="${ApiMessageEntityTypes.Spoiler}"
>${renderedContent}</span>`;
default:
return renderedContent;
}
}
function getLinkUrl(entityContent: string, entity: ApiMessageEntity) {
const { type, url } = entity;
return type === ApiMessageEntityTypes.TextUrl && url ? url : entityContent;
}
function handleBotCommandClick(e: MouseEvent<HTMLAnchorElement>) {
getActions().sendBotCommand({ command: e.currentTarget.innerText });
}
function handleHashtagClick(e: MouseEvent<HTMLAnchorElement>) {
getActions().setLocalTextSearchQuery({ query: e.currentTarget.innerText });
getActions().searchTextMessagesLocal();
}
function handleCodeClick(e: MouseEvent<HTMLElement>) {
copyTextToClipboard(e.currentTarget.innerText);
getActions().showNotification({
message: getTranslation('TextCopied'),
});
}