Telegram Web K with changes to work inside I2P https://web.telegram.i2p/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

890 lines
26 KiB

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import type {EMOJI_VERSION} from '../../environment/emojiVersionsSupport';
import {SITE_HASHTAGS} from '.';
import {EmojiVersions} from '../../config/emoji';
import IS_EMOJI_SUPPORTED from '../../environment/emojiSupport';
import {IS_SAFARI} from '../../environment/userAgent';
import buildURLHash from '../../helpers/buildURLHash';
import copy from '../../helpers/object/copy';
import encodeEntities from '../../helpers/string/encodeEntities';
import {MessageEntity} from '../../layer';
import encodeSpoiler from './encodeSpoiler';
import parseEntities from './parseEntities';
import setBlankToAnchor from './setBlankToAnchor';
import wrapUrl from './wrapUrl';
import EMOJI_VERSIONS_SUPPORTED from '../../environment/emojiVersionsSupport';
import {CLICK_EVENT_NAME} from '../../helpers/dom/clickEvent';
import IS_CUSTOM_EMOJI_SUPPORTED from '../../environment/customEmojiSupport';
import rootScope from '../rootScope';
import mediaSizes from '../../helpers/mediaSizes';
import {wrapSticker} from '../../components/wrappers';
import RLottiePlayer from '../rlottie/rlottiePlayer';
import animationIntersector from '../../components/animationIntersector';
import type {MyDocument} from '../appManagers/appDocsManager';
import LazyLoadQueue from '../../components/lazyLoadQueue';
import {Awaited} from '../../types';
import sequentialDom from '../../helpers/sequentialDom';
import {MediaSize} from '../../helpers/mediaSize';
import IS_WEBM_SUPPORTED from '../../environment/webmSupport';
const resizeObserver = new ResizeObserver((entries) => {
for(const entry of entries) {
const renderer = entry.target.parentElement as CustomEmojiRendererElement;
renderer.setDimensionsFromRect(entry.contentRect);
}
});
class CustomEmojiElement extends HTMLElement {
}
export class CustomEmojiRendererElement extends HTMLElement {
public canvas: HTMLCanvasElement;
public context: CanvasRenderingContext2D;
public players: Map<CustomEmojiElement[], RLottiePlayer>;
public clearedContainers: Set<CustomEmojiElement[]>;
public paused: boolean;
public autoplay: boolean;
public middleware: () => boolean;
public keys: string[];
constructor() {
super();
this.classList.add('custom-emoji-renderer');
this.canvas = document.createElement('canvas');
this.canvas.classList.add('custom-emoji-canvas');
this.context = this.canvas.getContext('2d');
this.append(this.canvas);
this.paused = false;
this.autoplay = true;
this.players = new Map();
this.clearedContainers = new Set();
this.keys = [];
}
public connectedCallback() {
// this.setDimensions();
animationIntersector.addAnimation(this, 'EMOJI');
resizeObserver.observe(this.canvas);
this.connectedCallback = undefined;
}
public disconnectedCallback() {
for(const key of this.keys) {
const l = lotties.get(key);
if(!l) {
continue;
}
if(!--l.counter) {
if(l.player instanceof RLottiePlayer) {
l.player.remove();
}
lotties.delete(key);
if(!lotties.size) {
clearRenderInterval();
}
}
}
resizeObserver.unobserve(this.canvas);
this.disconnectedCallback = undefined;
}
public getOffsets(offsetsMap: Map<CustomEmojiElement[], {top: number, left: number}[]> = new Map()) {
for(const [containers, player] of this.players) {
const offsets = containers.map((container) => {
return {
top: container.offsetTop,
left: container.offsetLeft
};
});
offsetsMap.set(containers, offsets);
}
return offsetsMap;
}
public clearCanvas() {
const {context, canvas} = this;
context.clearRect(0, 0, canvas.width, canvas.height);
}
public render(offsetsMap: ReturnType<CustomEmojiRendererElement['getOffsets']>) {
const {context, canvas} = this;
const {width, height, dpr} = canvas;
for(const [containers, player] of this.players) {
const frame = topFrames.get(player);
if(!frame) {
continue;
}
const isImageData = frame instanceof ImageData;
const {width: stickerWidth, height: stickerHeight} = player.canvas[0];
const offsets = offsetsMap.get(containers);
const maxTop = height - stickerHeight;
const maxLeft = width - stickerWidth;
if(!this.clearedContainers.has(containers)) {
containers.forEach((container) => {
container.textContent = '';
});
this.clearedContainers.add(containers);
}
offsets.forEach(({top, left}) => {
top = Math.round(top * dpr), left = Math.round(left * dpr);
if(/* top > maxTop || */left > maxLeft) {
return;
}
if(isImageData) {
context.putImageData(frame as ImageData, left, top);
} else {
// context.clearRect(left, top, width, height);
context.drawImage(frame as ImageBitmap, left, top, stickerWidth, stickerHeight);
}
});
}
}
public checkForAnyFrame() {
for(const [containers, player] of this.players) {
if(topFrames.has(player)) {
return true;
}
}
return false;
}
public pause() {
this.paused = true;
}
public play() {
this.paused = false;
}
public remove() {
this.canvas.remove();
}
public setDimensions() {
const {canvas} = this;
sequentialDom.mutateElement(canvas, () => {
const rect = canvas.getBoundingClientRect();
this.setDimensionsFromRect(rect);
});
}
public setDimensionsFromRect(rect: DOMRect) {
const {canvas} = this;
const dpr = canvas.dpr ??= Math.min(2, window.devicePixelRatio);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
}
}
type R = CustomEmojiRendererElement;
let renderInterval: number;
const top: Array<R> = [];
const topFrames: Map<RLottiePlayer, Parameters<RLottiePlayer['overrideRender']>[0]> = new Map();
const lotties: Map<string, {player: Promise<RLottiePlayer> | RLottiePlayer, middlewares: Set<() => boolean>, counter: number}> = new Map();
const rerere = () => {
const t = top.filter((r) => !r.paused && r.isConnected && r.checkForAnyFrame());
if(!t.length) {
return;
}
const offsetsMap: Map<CustomEmojiElement[], {top: number, left: number}[]> = new Map();
for(const r of t) {
r.getOffsets(offsetsMap);
}
for(const r of t) {
r.clearCanvas();
}
for(const r of t) {
r.render(offsetsMap);
}
};
const CUSTOM_EMOJI_FPS = 60;
const CUSTOM_EMOJI_FRAME_INTERVAL = 1000 / CUSTOM_EMOJI_FPS;
const setRenderInterval = () => {
if(renderInterval) {
return;
}
renderInterval = window.setInterval(rerere, CUSTOM_EMOJI_FRAME_INTERVAL);
rerere();
};
const clearRenderInterval = () => {
if(!renderInterval) {
return;
}
clearInterval(renderInterval);
renderInterval = undefined;
};
(window as any).lotties = lotties;
customElements.define('custom-emoji-element', CustomEmojiElement);
customElements.define('custom-emoji-renderer-element', CustomEmojiRendererElement);
/**
* * Expecting correctly sorted nested entities (RichTextProcessor.sortEntities)
*/
export default function wrapRichText(text: string, options: Partial<{
entities: MessageEntity[],
contextSite: string,
highlightUsername: string,
noLinks: boolean,
noLinebreaks: boolean,
noCommands: boolean,
wrappingDraft: boolean,
// mustWrapEmoji: boolean,
fromBot: boolean,
noTextFormat: boolean,
passEntities: Partial<{
[_ in MessageEntity['_']]: boolean
}>,
noEncoding: boolean,
contextHashtag?: string,
nasty?: {
i: number,
usedLength: number,
text: string,
lastEntity?: MessageEntity
},
voodoo?: boolean,
customEmojis?: {[docId: DocId]: CustomEmojiElement[]},
loadPromises?: Promise<any>[],
middleware?: () => boolean,
wrappingSpoiler?: boolean,
lazyLoadQueue?: LazyLoadQueue,
customEmojiSize?: MediaSize
}> = {}) {
const fragment = document.createDocumentFragment();
if(!text) {
return fragment;
}
const nasty = options.nasty ??= {
i: 0,
usedLength: 0,
text
};
const customEmojis = options.customEmojis ??= {};
const entities = options.entities ??= parseEntities(nasty.text);
const passEntities = options.passEntities ??= {};
const contextSite = options.contextSite ??= 'Telegram';
const contextExternal = contextSite !== 'Telegram';
const textLength = nasty.text.length;
const length = entities.length;
let lastElement: HTMLElement | DocumentFragment;
for(; nasty.i < length; ++nasty.i) {
let entity = entities[nasty.i];
// * check whether text was sliced
// TODO: consider about moving it to other function
if(entity.offset >= textLength) {
if(entity._ !== 'messageEntityCaret') { // * can set caret to the end
continue;
}
} else if((entity.offset + entity.length) > textLength) {
entity = copy(entity);
entity.length = entity.offset + entity.length - textLength;
}
if(entity.length) {
nasty.lastEntity = entity;
}
let nextEntity = entities[nasty.i + 1];
const startOffset = entity.offset;
const endOffset = startOffset + entity.length;
const endPartOffset = Math.min(endOffset, nextEntity?.offset ?? 0xFFFF);
const fullEntityText = nasty.text.slice(startOffset, endOffset);
const sliced = nasty.text.slice(startOffset, endPartOffset);
let partText = sliced;
if(nasty.usedLength < startOffset) {
(lastElement || fragment).append(nasty.text.slice(nasty.usedLength, startOffset));
}
if(lastElement) {
lastElement = fragment;
}
nasty.usedLength = endPartOffset;
let element: HTMLElement,
property: 'textContent' | 'alt' = 'textContent',
usedText = false;
switch(entity._) {
case 'messageEntityBold': {
if(!options.noTextFormat) {
if(options.wrappingDraft) {
element = document.createElement('span');
element.style.fontWeight = 'bold';
} else {
element = document.createElement('strong');
}
}
break;
}
case 'messageEntityItalic': {
if(!options.noTextFormat) {
if(options.wrappingDraft) {
element = document.createElement('span');
element.style.fontStyle = 'italic';
} else {
element = document.createElement('em');
}
}
break;
}
case 'messageEntityStrike': {
if(options.wrappingDraft) {
const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line';
element = document.createElement('span');
element.style.cssText = `${styleName}: line-through;`;
} else if(!options.noTextFormat) {
element = document.createElement('del');
}
break;
}
case 'messageEntityUnderline': {
if(options.wrappingDraft) {
const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line';
element = document.createElement('span');
element.style.cssText = `${styleName}: underline;`;
} else if(!options.noTextFormat) {
element = document.createElement('u');
}
break;
}
case 'messageEntityPre':
case 'messageEntityCode': {
if(options.wrappingDraft) {
element = document.createElement('span');
element.style.fontFamily = 'var(--font-monospace)';
} else if(!options.noTextFormat) {
element = document.createElement('code');
}
break;
}
// case 'messageEntityPre': {
// if(options.wrappingDraft) {
// element = document.createElement('span');
// element.style.fontFamily = 'var(--font-monospace)';
// } else if(!options.noTextFormat) {
// element = document.createElement('pre');
// const inner = document.createElement('code');
// if(entity.language) {
// inner.className = 'language-' + entity.language;
// inner.textContent = entityText;
// usedText = true;
// }
// }
// break;
// }
case 'messageEntityHighlight': {
element = document.createElement('i');
element.className = 'text-highlight';
break;
}
case 'messageEntityBotCommand': {
// if(!(options.noLinks || options.noCommands || contextExternal)/* && !entity.unsafe */) {
if(!options.noLinks && passEntities[entity._]) {
let command = fullEntityText.slice(1);
let bot: string | boolean;
let atPos: number;
if((atPos = command.indexOf('@')) !== -1) {
bot = command.slice(atPos + 1);
command = command.slice(0, atPos);
} else {
bot = options.fromBot;
}
element = document.createElement('a');
(element as HTMLAnchorElement).href = encodeEntities('tg://bot_command?command=' + encodeURIComponent(command) + (bot ? '&bot=' + encodeURIComponent(bot) : ''));
if(!contextExternal) {
element.setAttribute('onclick', 'execBotCommand(this)');
}
}
break;
}
case 'messageEntityCustomEmoji': {
if(!IS_CUSTOM_EMOJI_SUPPORTED) {
break;
}
if(nextEntity?._ === 'messageEntityEmoji') {
++nasty.i;
nasty.lastEntity = nextEntity;
nasty.usedLength += nextEntity.length;
nextEntity = entities[nasty.i + 1];
}
(customEmojis[entity.document_id] ??= []).push(element = new CustomEmojiElement());
element.classList.add('custom-emoji');
property = 'alt';
break;
}
case 'messageEntityEmoji': {
let isSupported = IS_EMOJI_SUPPORTED;
if(isSupported) {
for(const version in EmojiVersions) {
if(version) {
const emojiData = EmojiVersions[version as EMOJI_VERSION];
if(emojiData.hasOwnProperty(entity.unicode) && !EMOJI_VERSIONS_SUPPORTED[version as EMOJI_VERSION]) {
isSupported = false;
break;
}
}
}
}
// if(!(options.wrappingDraft && isSupported)) { // * fix safari emoji
if(!isSupported) { // no wrapping needed
// if(isSupported) { // ! contenteditable="false" нужен для поля ввода, иначе там будет меняться шрифт в Safari, или же рендерить смайлик напрямую, без контейнера
// insertPart(entity, '<span class="emoji">', '</span>');
// } else {
element = document.createElement('img');
(element as HTMLImageElement).src = `assets/img/emoji/${entity.unicode}.png`;
property = 'alt';
element.className = 'emoji';
// }
// } else if(options.mustWrapEmoji) {
} else if(!options.wrappingDraft) {
element = document.createElement('span');
element.className = 'emoji';
}/* else if(!IS_SAFARI) {
insertPart(entity, '<span class="emoji" contenteditable="false">', '</span>');
} */
/* if(!isSupported) {
insertPart(entity, `<img src="assets/img/emoji/${entity.unicode}.png" alt="`, `" class="emoji">`);
} */
break;
}
case 'messageEntityCaret': {
element = document.createElement('span');
element.className = 'composer-sel';
break;
}
// case 'messageEntityLinebreak': {
// if(options.noLinebreaks) {
// insertPart(entity, ' ');
// } else {
// insertPart(entity, '<br/>');
// }
// break;
// }
case 'messageEntityUrl':
case 'messageEntityTextUrl': {
if(!(options.noLinks && !passEntities[entity._])) {
// let inner: string;
let url: string = (entity as MessageEntity.messageEntityTextUrl).url || fullEntityText;
let masked = false;
let onclick: string;
const wrapped = wrapUrl(url, true);
url = wrapped.url;
onclick = wrapped.onclick;
if(entity._ === 'messageEntityTextUrl') {
if(nextEntity?._ === 'messageEntityUrl' &&
nextEntity.length === entity.length &&
nextEntity.offset === entity.offset) {
nasty.lastEntity = nextEntity;
++nasty.i;
}
if(url !== fullEntityText) {
masked = true;
}
} else {
// inner = encodeEntities(replaceUrlEncodings(entityText));
}
const currentContext = !!onclick;
if(!onclick && masked && !currentContext) {
onclick = 'showMaskedAlert';
}
if(options.wrappingDraft) {
onclick = undefined;
}
const href = (currentContext || typeof electronHelpers === 'undefined') ?
url :
`javascript:electronHelpers.openExternal('${url}');`;
element = document.createElement('a');
element.className = 'anchor-url';
(element as HTMLAnchorElement).href = href;
if(!(currentContext || typeof electronHelpers !== 'undefined')) {
setBlankToAnchor(element as HTMLAnchorElement);
}
if(onclick) {
element.setAttribute('onclick', onclick + '(this)');
}
}
break;
}
case 'messageEntityEmail': {
if(!options.noLinks) {
element = document.createElement('a');
(element as HTMLAnchorElement).href = encodeEntities('mailto:' + fullEntityText);
setBlankToAnchor(element as HTMLAnchorElement);
}
break;
}
case 'messageEntityHashtag': {
const contextUrl = !options.noLinks && SITE_HASHTAGS[contextSite];
if(contextUrl) {
const hashtag = fullEntityText.slice(1);
element = document.createElement('a');
element.className = 'anchor-hashtag';
(element as HTMLAnchorElement).href = contextUrl.replace('{1}', encodeURIComponent(hashtag));
if(contextExternal) {
setBlankToAnchor(element as HTMLAnchorElement);
} else {
element.setAttribute('onclick', 'searchByHashtag(this)');
}
}
break;
}
case 'messageEntityMentionName': {
if(!(options.noLinks && !passEntities[entity._])) {
element = document.createElement('a');
(element as HTMLAnchorElement).href = buildURLHash('' + entity.user_id);
element.className = 'follow';
element.dataset.follow = '' + entity.user_id;
}
break;
}
case 'messageEntityMention': {
// const contextUrl = !options.noLinks && siteMentions[contextSite];
if(!options.noLinks) {
const username = fullEntityText.slice(1);
const {url, onclick} = wrapUrl('t.me/' + username);
element = document.createElement('a');
element.className = 'mention';
(element as HTMLAnchorElement).href = url;
if(onclick) {
element.setAttribute('onclick', `${onclick}(this)`);
}
// insertPart(entity, `<a class="mention" href="${contextUrl.replace('{1}', encodeURIComponent(username))}"${contextExternal ? ' target="_blank" rel="noopener noreferrer"' : ''}>`, '</a>');
}
break;
}
case 'messageEntitySpoiler': {
if(options.noTextFormat) {
const encoded = encodeSpoiler(nasty.text, entity);
nasty.text = encoded.text;
partText = encoded.entityText;
nasty.usedLength += partText.length;
let n: MessageEntity;
for(; n = entities[nasty.i + 1], n && n.offset < endOffset;) {
// nasty.usedLength += n.length;
++nasty.i;
nasty.lastEntity = n;
nextEntity = entities[nasty.i + 1];
}
} else if(options.wrappingDraft) {
element = document.createElement('span');
element.style.fontFamily = 'spoiler';
} else {
const container = document.createElement('span');
container.className = 'spoiler';
element = document.createElement('span');
element.className = 'spoiler-text';
element.textContent = partText;
usedText = true;
container.append(element);
fragment.append(container);
container[`on${CLICK_EVENT_NAME}`] = (window as any).onSpoilerClick;
}
break;
}
}
if(!usedText) {
if(element) {
// @ts-ignore
element[property] = partText;
} else {
(element || fragment).append(partText);
}
}
if(element && !element.parentElement) {
(lastElement || fragment).append(element);
}
while(nextEntity && nextEntity.offset < endOffset) {
++nasty.i;
(element || fragment).append(wrapRichText(nasty.text, {
...options,
voodoo: true
}));
nextEntity = entities[nasty.i + 1];
}
// if(!element?.parentElement) {
// (lastElement || fragment).append(element ?? partText);
// }
if(nasty.usedLength <= endOffset) {
if(nasty.usedLength < endOffset) {
(element || fragment).append(nasty.text.slice(nasty.usedLength, endOffset));
nasty.usedLength = endOffset;
}
lastElement = fragment;
nasty.lastEntity = undefined;
} else if(entity.length > partText.length && element) {
lastElement = element;
} else {
lastElement = fragment;
}
if(options.voodoo) {
return fragment;
}
}
if(nasty.lastEntity) {
nasty.usedLength = nasty.lastEntity.offset + nasty.lastEntity.length;
}
if(nasty.usedLength < textLength) {
(lastElement || fragment).append(nasty.text.slice(nasty.usedLength));
}
const docIds = Object.keys(customEmojis) as DocId[];
if(docIds.length) {
const managers = rootScope.managers;
const middleware = options.middleware;
const renderer = new CustomEmojiRendererElement();
renderer.middleware = middleware;
top.push(renderer);
fragment.prepend(renderer);
const size = options.customEmojiSize || mediaSizes.active.customEmoji;
const loadPromise = managers.appEmojiManager.getCachedCustomEmojiDocuments(docIds).then((docs) => {
console.log(docs);
if(middleware && !middleware()) return;
const loadPromises: Promise<any>[] = [];
const wrap = (doc: MyDocument, _loadPromises?: Promise<any>[]): Promise<Awaited<ReturnType<typeof wrapSticker>> & {onRender?: () => void}> => {
const containers = customEmojis[doc.id];
const isLottie = doc.sticker === 2;
const loadPromises: Promise<any>[] = [];
const promise = wrapSticker({
div: containers,
doc,
width: size.width,
height: size.height,
loop: true,
play: true,
managers,
isCustomEmoji: true,
group: 'none',
loadPromises,
middleware,
exportLoad: true,
needFadeIn: false,
loadStickerMiddleware: isLottie && middleware ? () => {
if(lotties.get(key) !== l) {
return false;
}
let good = !l.middlewares.size;
for(const middleware of l.middlewares) {
if(middleware()) {
good = true;
}
}
return good;
} : undefined,
static: doc.mime_type === 'video/webm' && !IS_WEBM_SUPPORTED
});
if(_loadPromises) {
promise.then(() => _loadPromises.push(...loadPromises));
}
if(!isLottie) {
return promise;
}
const onRender = (player: Awaited<Awaited<typeof promise>['render']>) => Promise.all(loadPromises).then(() => {
if(player instanceof RLottiePlayer && (!middleware || middleware())) {
l.player = player;
const playerCanvas = player.canvas[0];
renderer.canvas.dpr = playerCanvas.dpr;
renderer.players.set(containers, player);
setRenderInterval();
player.overrideRender ??= (frame) => {
topFrames.set(player, frame);
// frames.set(containers, frame);
};
l.middlewares.delete(middleware);
}
});
const key = [doc.id, size.width, size.height].join('-');
renderer.keys.push(key);
let l = lotties.get(key);
if(!l) {
l = {
player: undefined,
middlewares: new Set(),
counter: 0
};
lotties.set(key, l);
}
++l.counter;
if(middleware) {
l.middlewares.add(middleware);
}
return promise.then((res) => ({...res, onRender}));
};
const missing: DocId[] = [];
const cachedPromises = docs.map((doc, idx) => {
if(!doc) {
missing.push(docIds[idx]);
return;
}
return wrap(doc, loadPromises);
}).filter(Boolean);
const uncachedPromisesPromise = managers.appEmojiManager.getCustomEmojiDocuments(missing).then((docs) => {
if(middleware && !middleware()) return [];
return docs.filter(Boolean).map((doc) => wrap(doc));
});
const loadFromPromises = (promises: typeof cachedPromises) => {
return Promise.all(promises).then((arr) => {
const promises = arr.map(({load, onRender}) => {
if(!load) {
return;
}
return load().then(onRender);
});
return Promise.all(promises);
});
};
const load = () => {
if(middleware && !middleware()) return;
const cached = loadFromPromises(cachedPromises);
const uncached = uncachedPromisesPromise.then((promises) => loadFromPromises(promises));
return Promise.all([cached, uncached]);
};
if(options.lazyLoadQueue) {
options.lazyLoadQueue.push({
div: renderer.canvas,
load
});
} else {
load();
}
return Promise.all(cachedPromises).then(() => Promise.all(loadPromises)).then(() => {});
});
// recordPromise(loadPromise, 'render emojis: ' + docIds.length);
options.loadPromises?.push(loadPromise);
}
return fragment;
}
(window as any).wrapRichText = wrapRichText;