From 0a5ea95c52751ef3b137455e7aacfb23c2b268a2 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 7 Nov 2022 23:00:45 +0400 Subject: [PATCH] Message, Composer: Support copy-paste formatting (#2096) --- src/api/gramjs/apiBuilders/messages.ts | 2 +- src/api/gramjs/methods/settings.ts | 1 - src/components/common/CustomEmoji.module.scss | 16 +++++ src/components/common/CustomEmoji.tsx | 6 ++ src/components/common/SafeLink.tsx | 5 +- src/components/common/code/CodeBlock.tsx | 6 +- .../common/helpers/renderMessageText.ts | 3 +- .../common/helpers/renderTextWithEntities.tsx | 16 +++-- src/components/common/spoiler/Spoiler.tsx | 8 ++- src/components/middle/composer/Composer.tsx | 58 +++++++++------ .../composer/ComposerEmbeddedMessage.tsx | 13 ++-- .../middle/composer/helpers/customEmoji.ts | 3 + .../composer/hooks/useClipboardPaste.ts | 72 +++++++++++++++++-- .../composer/inlineResults/BaseResult.tsx | 2 +- src/components/middle/message/MentionLink.tsx | 9 ++- src/components/middle/message/Message.tsx | 4 +- src/components/middle/message/MessageMeta.tsx | 1 + .../middle/message/SponsoredMessage.tsx | 4 +- .../middle/message/helpers/copyOptions.ts | 15 +++- src/global/actions/ui/messages.ts | 15 +++- .../helpers/renderMessageSummaryHtml.ts | 18 +++++ src/global/helpers/symbols.ts | 14 ++++ src/util/clipboard.ts | 14 ++++ src/util/parseMessageInput.ts | 17 +++-- 24 files changed, 258 insertions(+), 64 deletions(-) create mode 100644 src/global/helpers/renderMessageSummaryHtml.ts diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 563955905..d2ea56634 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -1259,7 +1259,7 @@ export function buildLocalForwardedMessage( const shouldDropCustomEmoji = !isCurrentUserPremium; const strippedText = content.text?.entities && shouldDropCustomEmoji ? { text: content.text.text, - entities: content.text.entities?.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), + entities: content.text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), } : content.text; const emojiOnlyCount = content.text && parseEmojiOnlyString(content.text.text); diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index bde73865d..9bfbd16ec 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -3,7 +3,6 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAppConfig, - ApiChat, ApiError, ApiLangString, ApiLanguage, diff --git a/src/components/common/CustomEmoji.module.scss b/src/components/common/CustomEmoji.module.scss index 881fb565d..cbb961395 100644 --- a/src/components/common/CustomEmoji.module.scss +++ b/src/components/common/CustomEmoji.module.scss @@ -28,8 +28,24 @@ border-radius: 0 !important; + user-select: none !important; + :global(canvas) { width: 100% !important; height: 100% !important; } } + +.highlightCatch { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + user-select: auto !important; +} + +.altEmoji { + opacity: 0; +} diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index 968c422b2..ac2c61796 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -5,6 +5,7 @@ import { getGlobal } from '../../global'; import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import { ApiMessageEntityTypes } from '../../api/types'; import { getPropertyHexColor } from '../../util/themeStyle'; import { hexToRgb } from '../../util/switchTheme'; @@ -19,6 +20,7 @@ import StickerView from './StickerView'; import styles from './CustomEmoji.module.scss'; import svgPlaceholder from '../../assets/square.svg'; +import blankImg from '../../assets/blank.png'; type OwnProps = { ref?: React.RefObject; @@ -121,8 +123,12 @@ const CustomEmoji: FC = ({ withGridFix && styles.withGridFix, )} onClick={onClick} + data-entity-type={ApiMessageEntityTypes.CustomEmoji} + data-document-id={documentId} + data-alt={customEmoji?.emoji} style={style} > + {customEmoji?.emoji} {!customEmoji ? ( Emoji ) : ( diff --git a/src/components/common/SafeLink.tsx b/src/components/common/SafeLink.tsx index 5a9c44685..c9792658f 100644 --- a/src/components/common/SafeLink.tsx +++ b/src/components/common/SafeLink.tsx @@ -1,8 +1,10 @@ -import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback } from '../../lib/teact/teact'; import { getActions } from '../../global'; import convertPunycode from '../../lib/punycode'; +import type { FC } from '../../lib/teact/teact'; +import { ApiMessageEntityTypes } from '../../api/types'; + import { DEBUG, } from '../../config'; @@ -55,6 +57,7 @@ const SafeLink: FC = ({ className={classNames} onClick={handleClick} dir={isRtl ? 'rtl' : 'auto'} + data-entity-type={ApiMessageEntityTypes.Url} > {content} diff --git a/src/components/common/code/CodeBlock.tsx b/src/components/common/code/CodeBlock.tsx index 17e4553f3..2235cf61b 100644 --- a/src/components/common/code/CodeBlock.tsx +++ b/src/components/common/code/CodeBlock.tsx @@ -1,6 +1,8 @@ -import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useState } from '../../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; +import { ApiMessageEntityTypes } from '../../../api/types'; + import buildClassName from '../../../util/buildClassName'; import useAsync from '../../../hooks/useAsync'; @@ -36,7 +38,7 @@ const CodeBlock: FC = ({ text, language, noCopy }) => { const blockClass = buildClassName('code-block', !isWordWrap && 'no-word-wrap'); return ( -
+    
       {highlighted}
       {renderNestedMessagePart()};
+      return {renderNestedMessagePart()};
     case ApiMessageEntityTypes.Blockquote:
-      return 
{renderNestedMessagePart()}
; + return
{renderNestedMessagePart()}
; case ApiMessageEntityTypes.BotCommand: return ( {renderNestedMessagePart()} @@ -354,6 +355,7 @@ function processEntity( onClick={handleHashtagClick} className="text-entity-link" dir="auto" + data-entity-type={entity.type} > {renderNestedMessagePart()} @@ -364,6 +366,7 @@ function processEntity( onClick={handleHashtagClick} className="text-entity-link" dir="auto" + data-entity-type={entity.type} > {renderNestedMessagePart()} @@ -375,6 +378,7 @@ function processEntity( onClick={!isProtected ? handleCodeClick : undefined} role="textbox" tabIndex={0} + data-entity-type={entity.type} > {renderNestedMessagePart()} @@ -387,12 +391,13 @@ function processEntity( rel="noopener noreferrer" className="text-entity-link" dir="auto" + data-entity-type={entity.type} > {renderNestedMessagePart()} ); case ApiMessageEntityTypes.Italic: - return {renderNestedMessagePart()}; + return {renderNestedMessagePart()}; case ApiMessageEntityTypes.MentionName: return ( @@ -411,6 +416,7 @@ function processEntity( href={`tel:${entityText}`} className="text-entity-link" dir="auto" + data-entity-type={entity.type} > {renderNestedMessagePart()} @@ -418,7 +424,7 @@ function processEntity( case ApiMessageEntityTypes.Pre: return ; case ApiMessageEntityTypes.Strike: - return {renderNestedMessagePart()}; + return {renderNestedMessagePart()}; case ApiMessageEntityTypes.TextUrl: case ApiMessageEntityTypes.Url: return ( @@ -430,7 +436,7 @@ function processEntity( ); case ApiMessageEntityTypes.Underline: - return {renderNestedMessagePart()}; + return {renderNestedMessagePart()}; case ApiMessageEntityTypes.Spoiler: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.CustomEmoji: diff --git a/src/components/common/spoiler/Spoiler.tsx b/src/components/common/spoiler/Spoiler.tsx index f348d70f0..255933531 100644 --- a/src/components/common/spoiler/Spoiler.tsx +++ b/src/components/common/spoiler/Spoiler.tsx @@ -1,9 +1,10 @@ -import type { MouseEvent as ReactMouseEvent } from 'react'; -import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; +import { ApiMessageEntityTypes } from '../../../api/types'; + import { createClassNameBuilder } from '../../../util/buildClassName'; import useFlag from '../../../hooks/useFlag'; @@ -34,7 +35,7 @@ const Spoiler: FC = ({ const [isRevealed, reveal, conceal] = useFlag(); - const handleClick = useCallback((e: ReactMouseEvent) => { + const handleClick = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -74,6 +75,7 @@ const Spoiler: FC = ({ !isRevealed && Boolean(messageId) && 'animated', )} onClick={messageId && !isRevealed ? handleClick : undefined} + data-entity-type={ApiMessageEntityTypes.Spoiler} > {children} diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index bd0dcd09b..1bbd2f0ac 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -78,6 +78,7 @@ import { getServerTime } from '../../../util/serverTime'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import { buildCustomEmojiHtml } from './helpers/customEmoji'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; +import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities'; import useFlag from '../../../hooks/useFlag'; import usePrevious from '../../../hooks/usePrevious'; @@ -512,6 +513,13 @@ const Composer: FC = ({ }); }, [htmlRef, setHtml]); + const insertFormattedTextAndUpdateCursor = useCallback(( + text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID, + ) => { + const newHtml = getTextWithEntitiesAsHtml(text); + insertHtmlAndUpdateCursor(newHtml, inputId); + }, [insertHtmlAndUpdateCursor]); + const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => { const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html']) .join('') @@ -565,6 +573,24 @@ const Composer: FC = ({ }; }, [chatId, resetComposer, stopRecordingVoiceRef]); + const showCustomEmojiPremiumNotification = useCallback(() => { + const notificationNumber = customEmojiNotificationNumber.current; + if (!notificationNumber) { + showNotification({ + message: lang('UnlockPremiumEmojiHint'), + action: () => openPremiumModal({ initialSection: 'animated_emoji' }), + actionText: lang('PremiumMore'), + }); + } else { + showNotification({ + message: lang('UnlockPremiumEmojiHint2'), + action: () => openChat({ id: currentUserId, shouldReplaceHistory: true }), + actionText: lang('Open'), + }); + } + customEmojiNotificationNumber.current = Number(!notificationNumber); + }, [currentUserId, lang, openChat, openPremiumModal, showNotification]); + const [handleEditComplete, handleEditCancel] = useEditing( htmlRef, setHtml, @@ -578,7 +604,14 @@ const Composer: FC = ({ editingDraft, ); useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage, lastSyncTime); - useClipboardPaste(isForCurrentMessageList, insertTextAndUpdateCursor, handleSetAttachments, editingMessage); + useClipboardPaste( + isForCurrentMessageList, + insertFormattedTextAndUpdateCursor, + handleSetAttachments, + editingMessage, + !isCurrentUserPremium && !isChatWithSelf, + showCustomEmojiPremiumNotification, + ); const handleEmbeddedClear = useCallback(() => { if (editingMessage) { @@ -775,29 +808,12 @@ const Composer: FC = ({ const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { - const notificationNumber = customEmojiNotificationNumber.current; - if (!notificationNumber) { - showNotification({ - message: lang('UnlockPremiumEmojiHint'), - action: () => openPremiumModal({ initialSection: 'animated_emoji' }), - actionText: lang('PremiumMore'), - }); - } else { - showNotification({ - message: lang('UnlockPremiumEmojiHint2'), - action: () => openChat({ id: currentUserId, shouldReplaceHistory: true }), - actionText: lang('Open'), - }); - } - customEmojiNotificationNumber.current = Number(!notificationNumber); + showCustomEmojiPremiumNotification(); return; } insertCustomEmojiAndUpdateCursor(emoji); - }, [ - currentUserId, insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, lang, - openChat, openPremiumModal, showNotification, - ]); + }, [insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, showCustomEmojiPremiumNotification]); const handleStickerSelect = useCallback(( sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, @@ -1396,7 +1412,7 @@ export default memo(withGlobal( const { currentUserId } = global; const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined; const sendAsId = chat?.sendAsPeerIds && defaultSendAsId - && chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId) ? defaultSendAsId + && chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId) ? defaultSendAsId : (chat?.adminRights?.anonymous ? chat?.id : undefined); const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index a73e18366..c89237bc1 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -5,7 +5,6 @@ import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types'; -import { ApiMessageEntityTypes } from '../../../api/types'; import { selectChat, @@ -23,7 +22,7 @@ import { } from '../../../global/selectors'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import buildClassName from '../../../util/buildClassName'; -import { isUserId } from '../../../global/helpers'; +import { isUserId, stripCustomEmoji } from '../../../global/helpers'; import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useShowTransition from '../../../hooks/useShowTransition'; @@ -164,18 +163,14 @@ const ComposerEmbeddedMessage: FC = ({ : undefined; const strippedMessage = useMemo(() => { - const textEntities = message?.content.text?.entities; - if (!message || !isForwarding || !textEntities?.length || !noAuthors || isCurrentUserPremium) return message; + if (!message || !isForwarding || !message.content.text || !noAuthors || isCurrentUserPremium) return message; - const filteredEntities = textEntities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji); + const strippedText = stripCustomEmoji(message.content.text); return { ...message, content: { ...message.content, - text: { - text: message.content.text!.text, - entities: filteredEntities, - }, + text: strippedText, }, }; }, [isCurrentUserPremium, isForwarding, message, noAuthors]); diff --git a/src/components/middle/composer/helpers/customEmoji.ts b/src/components/middle/composer/helpers/customEmoji.ts index 8b5b3084a..9f4acba7a 100644 --- a/src/components/middle/composer/helpers/customEmoji.ts +++ b/src/components/middle/composer/helpers/customEmoji.ts @@ -1,4 +1,5 @@ import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types'; +import { ApiMessageEntityTypes } from '../../../../api/types'; import { EMOJI_SIZES } from '../../../../config'; import { REM } from '../../../common/helpers/mediaDimensions'; @@ -16,6 +17,7 @@ export function buildCustomEmojiHtml(emoji: ApiSticker) { draggable="false" alt="${emoji.emoji}" data-document-id="${emoji.id}" + data-entity-type="${ApiMessageEntityTypes.CustomEmoji}" src="${mediaData || placeholderSrc}" />`; } @@ -28,6 +30,7 @@ export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessa draggable="false" alt="${rawText}" data-document-id="${entity.documentId}" + data-entity-type="${ApiMessageEntityTypes.CustomEmoji}" src="${mediaData || placeholderSrc}" />`; } diff --git a/src/components/middle/composer/hooks/useClipboardPaste.ts b/src/components/middle/composer/hooks/useClipboardPaste.ts index ed5f276b9..0ce31433e 100644 --- a/src/components/middle/composer/hooks/useClipboardPaste.ts +++ b/src/components/middle/composer/hooks/useClipboardPaste.ts @@ -1,19 +1,67 @@ import type { StateHookSetter } from '../../../../lib/teact/teact'; import { useEffect } from '../../../../lib/teact/teact'; -import type { ApiAttachment, ApiMessage } from '../../../../api/types'; + +import type { ApiAttachment, ApiFormattedText, ApiMessage } from '../../../../api/types'; +import { ApiMessageEntityTypes } from '../../../../api/types'; import buildAttachment from '../helpers/buildAttachment'; import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../../config'; import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems'; +import parseMessageInput, { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseMessageInput'; +import { containsCustomEmoji, stripCustomEmoji } from '../../../../global/helpers/symbols'; const CLIPBOARD_ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/gif']; const MAX_MESSAGE_LENGTH = 4096; +const STYLE_TAG_REGEX = /