From 5f6463b8705649d3d16b1c063e2bb104843dffb1 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Tue, 18 Jan 2022 19:40:07 +0400 Subject: [PATCH] Spoilers --- src/components/chat/bubbles.ts | 26 ++++ src/components/chat/input.ts | 6 +- src/components/chat/replyContainer.ts | 2 +- src/helpers/dom/getRichElementValue.ts | 8 +- src/lib/appManagers/appMessagesManager.ts | 24 ++-- src/lib/richtextprocessor.ts | 150 +++++++++++++--------- src/scss/partials/_spoiler.scss | 66 ++++++++++ src/scss/style.scss | 5 + 8 files changed, 215 insertions(+), 72 deletions(-) create mode 100644 src/scss/partials/_spoiler.scss diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 7f76bca9..4d94b42e 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -1048,6 +1048,32 @@ export default class ChatBubbles { return; } + const spoiler: HTMLElement = findUpClassName(target, 'spoiler'); + if(spoiler) { + const messageDiv = findUpClassName(spoiler, 'message'); + + const className = 'is-spoiler-visible'; + const isVisible = messageDiv.classList.contains(className); + if(!isVisible) { + cancelEvent(e); + } + + const duration = 400 / 2; + const showDuration = 5000; + const useRafs = !isVisible ? 1 : 0; + if(useRafs) { + messageDiv.classList.add('will-change'); + } + + SetTransition(messageDiv, className, true, duration + showDuration, () => { + SetTransition(messageDiv, className, false, duration, () => { + messageDiv.classList.remove('will-change'); + }); + }, useRafs); + + return; + } + const commentsDiv: HTMLElement = findUpClassName(target, 'replies'); if(commentsDiv) { const bubbleMid = +bubble.dataset.mid; diff --git a/src/components/chat/input.ts b/src/components/chat/input.ts index 138861a0..161db6b6 100644 --- a/src/components/chat/input.ts +++ b/src/components/chat/input.ts @@ -1368,7 +1368,8 @@ export default class ChatInput { underline: 'Underline', strikethrough: 'Strikethrough', monospace: () => document.execCommand('fontName', false, 'monospace'), - link: href ? () => document.execCommand('createLink', false, href) : () => document.execCommand('unlink', false, null) + link: href ? () => document.execCommand('createLink', false, href) : () => document.execCommand('unlink', false, null), + spoiler: () => document.execCommand('fontName', false, 'spoiler') }; if(!commandsMap[type]) { @@ -1463,7 +1464,8 @@ export default class ChatInput { 'KeyI': 'italic', 'KeyU': 'underline', 'KeyS': 'strikethrough', - 'KeyM': 'monospace' + 'KeyM': 'monospace', + 'KeyP': 'spoiler' }; if(this.appImManager.markupTooltip) { diff --git a/src/components/chat/replyContainer.ts b/src/components/chat/replyContainer.ts index b2f1ee9e..deb1f49d 100644 --- a/src/components/chat/replyContainer.ts +++ b/src/components/chat/replyContainer.ts @@ -96,7 +96,7 @@ export function wrapReplyDivAndCaption(options: { } else { if(message) { subtitleEl.textContent = ''; - subtitleEl.append(appMessagesManager.wrapMessageForReply(message, message.message && limitSymbols(message.message, 140))); + subtitleEl.append(appMessagesManager.wrapMessageForReply(message)); } else { if(typeof(subtitle) === 'string') { subtitle = limitSymbols(subtitle, 140); diff --git a/src/helpers/dom/getRichElementValue.ts b/src/helpers/dom/getRichElementValue.ts index 0d2d5f84..a28f5956 100644 --- a/src/helpers/dom/getRichElementValue.ts +++ b/src/helpers/dom/getRichElementValue.ts @@ -11,10 +11,10 @@ import { MessageEntity } from "../../layer"; -export type MarkdownType = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'monospace' | 'link' | 'mentionName'; +export type MarkdownType = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'monospace' | 'link' | 'mentionName' | 'spoiler'; export type MarkdownTag = { match: string, - entityName: 'messageEntityBold' | 'messageEntityUnderline' | 'messageEntityItalic' | 'messageEntityPre' | 'messageEntityStrike' | 'messageEntityTextUrl' | 'messageEntityMentionName'; + entityName: Extract; }; // https://core.telegram.org/bots/api#html-style @@ -46,6 +46,10 @@ export const markdownTags: {[type in MarkdownType]: MarkdownTag} = { mentionName: { match: 'A.follow', entityName: 'messageEntityMentionName' + }, + spoiler: { + match: '[style*="spoiler"]', + entityName: 'messageEntitySpoiler' } }; diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index 4dfe03a4..33f7fe58 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -2531,12 +2531,12 @@ export class AppMessagesManager { messageId: mid }; - if(isMessage) { + /* if(isMessage) { const entities = message.entities; if(entities && entities.find(entity => entity._ === 'messageEntitySpoiler')) { message.media = {_: 'messageMediaUnsupported'}; } - } + } */ if(isMessage && message.media) { switch(message.media._) { @@ -2802,6 +2802,7 @@ export class AppMessagesManager { } }; + let entities = (message as Message.message).totalEntities; if((message as Message.message).media) { assumeType(message); let usingFullAlbum = true; @@ -2821,7 +2822,9 @@ export class AppMessagesManager { } if(usingFullAlbum) { - text = this.getAlbumText(message.grouped_id).message; + const albumText = this.getAlbumText(message.grouped_id); + text = albumText.message; + entities = albumText.totalEntities; if(!withoutMediaType) { addPart('AttachAlbum'); @@ -2859,8 +2862,8 @@ export class AppMessagesManager { addPart('AttachContact'); break; case 'messageMediaGame': { - const prefix = '🎮' + ' '; - text = prefix + media.game.title; + const f = '🎮' + ' ' + media.game.title; + addPart(undefined, plain ? f : RichTextProcessor.wrapEmojiText(f)); break; } case 'messageMediaDocument': { @@ -2924,14 +2927,17 @@ export class AppMessagesManager { if(text) { text = limitSymbols(text, 100); + if(!entities) { + entities = []; + } + if(plain) { - parts.push(text); + parts.push(RichTextProcessor.wrapPlainText(text, entities)); } else { - let entities = RichTextProcessor.parseEntities(text.replace(/\n/g, ' ')); + // let entities = RichTextProcessor.parseEntities(text.replace(/\n/g, ' ')); if(highlightWord) { highlightWord = highlightWord.trim(); - if(!entities) entities = []; let found = false; let match: any; let regExp = new RegExp(escapeRegExp(highlightWord), 'gi'); @@ -2941,7 +2947,7 @@ export class AppMessagesManager { } if(found) { - entities.sort((a, b) => a.offset - b.offset); + RichTextProcessor.sortEntities(entities); } } diff --git a/src/lib/richtextprocessor.ts b/src/lib/richtextprocessor.ts index b8551f8a..f3065e66 100644 --- a/src/lib/richtextprocessor.ts +++ b/src/lib/richtextprocessor.ts @@ -18,6 +18,7 @@ import { encodeEntities } from '../helpers/string'; import { IS_SAFARI } from '../environment/userAgent'; import { MOUNT_CLASS_TO } from '../config/debug'; import IS_EMOJI_SUPPORTED from '../environment/emojiSupport'; +import { copy } from '../helpers/object'; const EmojiHelper = { emojiMap: (code: string) => { return code; }, @@ -82,7 +83,7 @@ const botCommandRegExp = '\\/([a-zA-Z\\d_]{1,32})(?:@(' + usernameRegExp + '))?( const fullRegExp = new RegExp('(^| )(@)(' + usernameRegExp + ')|(' + urlRegExp + ')|(\\n)|(' + emojiRegExp + ')|(^|[\\s\\(\\]])(#[' + alphaNumericRegExp + ']{2,64})|(^|\\s)' + botCommandRegExp, 'i'); const emailRegExp = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; //const markdownTestRegExp = /[`_*@~]/; -const markdownRegExp = /(^|\s|\n)(````?)([\s\S]+?)(````?)([\s\n\.,:?!;]|$)|(^|\s|\x01)(`|~~|\*\*|__|_-_)([^\n]+?)\7([\x01\s\.,:?!;]|$)|@(\d+)\s*\((.+?)\)|(\[(.+?)\]\((.+?)\))/m; +const markdownRegExp = /(^|\s|\n)(````?)([\s\S]+?)(````?)([\s\n\.,:?!;]|$)|(^|\s|\x01)(`|~~|\*\*|__|_-_|\|\|)([^\n]+?)\7([\x01\s\.,:?!;]|$)|@(\d+)\s*\((.+?)\)|(\[(.+?)\]\((.+?)\))/m; const siteHashtags: {[siteName: string]: string} = { Telegram: 'tg://search_hashtag?hashtag={1}', Twitter: 'https://twitter.com/hashtag/{1}', @@ -102,7 +103,8 @@ const markdownEntities: {[markdown: string]: MessageEntity['_']} = { '**': 'messageEntityBold', '__': 'messageEntityItalic', '~~': 'messageEntityStrike', - '_-_': 'messageEntityUnderline' + '_-_': 'messageEntityUnderline', + '||': 'messageEntitySpoiler' }; const passConflictingEntities: Set = new Set([ @@ -287,7 +289,7 @@ namespace RichTextProcessor { const isSOH = match[6] === '\x01'; entity = { - _: markdownEntities[match[7]] as (MessageEntity.messageEntityBold | MessageEntity.messageEntityCode | MessageEntity.messageEntityItalic)['_'], + _: markdownEntities[match[7]] as (MessageEntity.messageEntityBold | MessageEntity.messageEntityCode | MessageEntity.messageEntityItalic | MessageEntity.messageEntitySpoiler)['_'], //offset: matchIndex + match[6].length, offset: matchIndex + (isSOH ? 0 : match[6].length), length: text.length @@ -407,7 +409,9 @@ namespace RichTextProcessor { // currentEntities.sort((a, b) => a.offset - b.offset); // currentEntities.sort((a, b) => (a.offset - b.offset) || (a._ === 'messageEntityCaret' && -1)); - if(!IS_EMOJI_SUPPORTED) { // fix splitted emoji. messageEntityTextUrl can split the emoji if starts before its end (e.g. on fe0f) + // * fix splitted emoji. messageEntityTextUrl can split the emoji if starts before its end (e.g. on fe0f) + // * have to fix even if emoji supported since it's being wrapped in span + // if(!IS_EMOJI_SUPPORTED) { for(let i = 0; i < currentEntities.length; ++i) { const entity = currentEntities[i]; if(entity._ === 'messageEntityEmoji') { @@ -417,7 +421,7 @@ namespace RichTextProcessor { } } } - } + // } return currentEntities; } @@ -466,16 +470,17 @@ namespace RichTextProcessor { entities: MessageEntity[], contextSite: string, highlightUsername: string, - noLinks: true, - noLinebreaks: true, - noCommands: true, + noLinks: boolean, + noLinebreaks: boolean, + noCommands: boolean, wrappingDraft: boolean, //mustWrapEmoji: boolean, fromBot: boolean, - noTextFormat: true, + noTextFormat: boolean, passEntities: Partial<{ [_ in MessageEntity['_']]: boolean }>, + noEncoding: boolean, contextHashtag?: string, }> = {}) { @@ -495,17 +500,50 @@ namespace RichTextProcessor { const contextExternal = contextSite !== 'Telegram'; const insertPart = (entity: MessageEntity, startPart: string, endPart?: string/* , priority = 0 */) => { - lol.push({part: startPart, offset: entity.offset/* , priority */}); + const startOffset = entity.offset, endOffset = endPart ? entity.offset + entity.length : undefined; + let startIndex: number, endIndex: number, length = lol.length; + for(let i = length - 1; i >= 0; --i) { + const offset = lol[i].offset; + + if(startIndex === undefined && startOffset >= offset) { + startIndex = i + 1; + } + + if(endOffset !== undefined) { + if(endOffset <= offset) { + endIndex = i; + } + } + + if(startOffset > offset && (endOffset === undefined || endOffset < offset)) { + break; + } + } + + startIndex ??= 0; + lol.splice(startIndex, 0, {part: startPart, offset: entity.offset/* , priority */}); - if(endPart) { - lol.push({part: endPart, offset: entity.offset + entity.length/* , priority */}); + if(endOffset !== undefined) { + endIndex ??= startIndex; + ++endIndex; + lol.splice(endIndex, 0, {part: endPart, offset: entity.offset + entity.length/* , priority */}); } }; const pushPartsAfterSort: typeof lol = []; - + const textLength = text.length; for(let i = 0, length = entities.length; i < length; ++i) { - const entity = entities[i]; + let entity = entities[i]; + + // * check whether text was sliced + // TODO: consider about moving it to other function + if(entity.offset >= textLength) { + continue; + } else if((entity.offset + entity.length) > textLength) { + entity = copy(entity); + entity.length = entity.offset + entity.length - textLength; + } + switch(entity._) { case 'messageEntityBold': { if(!options.noTextFormat) { @@ -535,7 +573,7 @@ namespace RichTextProcessor { if(options.wrappingDraft) { const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line'; insertPart(entity, ``, ''); - } else { + } else if(!options.noTextFormat) { insertPart(entity, '', ''); } @@ -546,7 +584,7 @@ namespace RichTextProcessor { if(options.wrappingDraft) { const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line'; insertPart(entity, ``, ''); - } else { + } else if(!options.noTextFormat) { insertPart(entity, '', ''); } @@ -556,7 +594,7 @@ namespace RichTextProcessor { case 'messageEntityCode': { if(options.wrappingDraft) { insertPart(entity, '', ''); - } else { + } else if(!options.noTextFormat) { insertPart(entity, '', ''); } @@ -732,14 +770,28 @@ namespace RichTextProcessor { break; } + + case 'messageEntitySpoiler': { + if(options.noTextFormat) { + const before = text.slice(0, entity.offset); + const after = text.slice(entity.offset + entity.length); + text = before + '▚'.repeat(entity.length) + after; + } else if(options.wrappingDraft) { + insertPart(entity, '', ''); + } else { + insertPart(entity, '', ''); + } + + break; + } } } // lol.sort((a, b) => (a.offset - b.offset) || (a.priority - b.priority)); - lol.sort((a, b) => a.offset - b.offset); // have to sort because of nested entities + // lol.sort((a, b) => a.offset - b.offset); // have to sort because of nested entities - let partsLength = lol.length, partsAfterSortLength = pushPartsAfterSort.length; - for(let i = 0; i < partsAfterSortLength; ++i) { + let partsLength = lol.length, pushPartsAfterSortLength = pushPartsAfterSort.length; + for(let i = 0; i < pushPartsAfterSortLength; ++i) { const part = pushPartsAfterSort[i]; let insertAt = 0; while(insertAt < partsLength) { @@ -751,14 +803,15 @@ namespace RichTextProcessor { lol.splice(insertAt, 0, part); } - partsLength += partsAfterSortLength; + partsLength += pushPartsAfterSortLength; const arr: string[] = []; let usedLength = 0; for(let i = 0; i < partsLength; ++i) { const {part, offset} = lol[i]; if(offset > usedLength) { - arr.push(encodeEntities(text.slice(usedLength, offset))); + const sliced = text.slice(usedLength, offset); + arr.push(options.noEncoding ? sliced : encodeEntities(sliced)); usedLength = offset; } @@ -766,7 +819,8 @@ namespace RichTextProcessor { } if(usedLength < text.length) { - arr.push(encodeEntities(text.slice(usedLength))); + const sliced = text.slice(usedLength); + arr.push(options.noEncoding ? sliced : encodeEntities(sliced)); } return arr.join(''); @@ -834,7 +888,7 @@ namespace RichTextProcessor { return url; } - export function replaceUrlEncodings(urlWithEncoded: string) { + /* export function replaceUrlEncodings(urlWithEncoded: string) { return urlWithEncoded.replace(/(%[A-Z\d]{2})+/g, (str) => { try { return decodeURIComponent(str); @@ -842,43 +896,23 @@ namespace RichTextProcessor { return str; } }); - } - - export function wrapPlainText(text: string) { - if(IS_EMOJI_SUPPORTED) { - return text; - } + } */ - if(!text || !text.length) { - return ''; + /** + * ! This function is still unsafe to use with .innerHTML + */ + export function wrapPlainText(text: string, entities?: MessageEntity[]) { + if(entities?.length) { + entities = entities.filter(entity => entity._ === 'messageEntitySpoiler'); } - text = text.replace(/\ufe0f/g, ''); - var match; - var raw = text; - const arr: string[] = []; - let emojiTitle; - fullRegExp.lastIndex = 0; - while((match = raw.match(fullRegExp))) { - arr.push(raw.substr(0, match.index)) - if(match[8]) { - // @ts-ignore - const emojiCode = EmojiHelper.emojiMap[match[8]]; - if(emojiCode && - // @ts-ignore - (emojiTitle = emojiData[emojiCode][1][0])) { - arr.push(':' + emojiTitle + ':'); - } else { - arr.push(match[0]); - } - } else { - arr.push(match[0]); - } - - raw = raw.substr(match.index + match[0].length); - } - arr.push(raw); - return arr.join(''); + return wrapRichText(text, { + entities, + noEncoding: true, + noTextFormat: true, + noLinebreaks: true, + noLinks: true + }); } export function wrapEmojiText(text: string, isDraft = false) { diff --git a/src/scss/partials/_spoiler.scss b/src/scss/partials/_spoiler.scss new file mode 100644 index 00000000..2b063511 --- /dev/null +++ b/src/scss/partials/_spoiler.scss @@ -0,0 +1,66 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.spoiler { + --anim: .4s ease; + position: relative; + background-color: var(--spoiler-background-color); + + &-text { + opacity: 0; + } + + /* &-draft { + background-color: var(--spoiler-draft-background-color); + } */ +} + +[style*="spoiler"] { + background-color: var(--spoiler-draft-background-color); + font-family: inherit !important; +} + +.message { + &.will-change { + .spoiler { + // box-shadow: 0 0 var(--spoiler-background-color); + + &-text { + filter: blur(6px); + } + } + } + + &.is-spoiler-visible { + &.animating { + .spoiler { + transition: /* box-shadow var(--anim), */ background-color var(--anim); + + &-text { + transition: opacity var(--anim), filter var(--anim); + } + } + } + + &:not(.backwards) { + .spoiler { + background-color: transparent; + // box-shadow: 0 0 30px 30px transparent; + + &-text { + filter: blur(0); + opacity: 1; + } + } + } + + &.backwards { + .spoiler-text { + filter: blur(3px); + } + } + } +} diff --git a/src/scss/style.scss b/src/scss/style.scss index 45c8b8d7..2ac9f84d 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -175,6 +175,8 @@ $chat-input-inner-padding-handhelds: .25rem; --link-color: #00488f; --ripple-color: rgba(0, 0, 0, .08); --poll-circle-color: var(--border-color); + --spoiler-background-color: #e3e5e8; + --spoiler-draft-background-color: #d9d9d9; --message-background-color: var(--surface-color); --message-checkbox-color: #61c642; @@ -241,6 +243,8 @@ $chat-input-inner-padding-handhelds: .25rem; --link-color: var(--primary-color); --ripple-color: rgba(255, 255, 255, .08); --poll-circle-color: #fff; + --spoiler-background-color: #373e4e; + --spoiler-draft-background-color: #484848; --message-background-color: var(--surface-color); --message-checkbox-color: var(--primary-color); @@ -300,6 +304,7 @@ $chat-input-inner-padding-handhelds: .25rem; @import "partials/colorPicker"; @import "partials/replyKeyboard"; @import "partials/peopleNearby"; +@import "partials/spoiler"; @import "partials/popups/popup"; @import "partials/popups/editAvatar";