Message, Composer: Support copy-paste formatting (#2096)

This commit is contained in:
Alexander Zinchuk 2022-11-07 23:00:45 +04:00
parent b69d4ee2f1
commit 0a5ea95c52
24 changed files with 258 additions and 64 deletions

View File

@ -1259,7 +1259,7 @@ export function buildLocalForwardedMessage(
const shouldDropCustomEmoji = !isCurrentUserPremium; const shouldDropCustomEmoji = !isCurrentUserPremium;
const strippedText = content.text?.entities && shouldDropCustomEmoji ? { const strippedText = content.text?.entities && shouldDropCustomEmoji ? {
text: content.text.text, 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; } : content.text;
const emojiOnlyCount = content.text && parseEmojiOnlyString(content.text.text); const emojiOnlyCount = content.text && parseEmojiOnlyString(content.text.text);

View File

@ -3,7 +3,6 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiAppConfig, ApiAppConfig,
ApiChat,
ApiError, ApiError,
ApiLangString, ApiLangString,
ApiLanguage, ApiLanguage,

View File

@ -28,8 +28,24 @@
border-radius: 0 !important; border-radius: 0 !important;
user-select: none !important;
:global(canvas) { :global(canvas) {
width: 100% !important; width: 100% !important;
height: 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;
}

View File

@ -5,6 +5,7 @@ import { getGlobal } from '../../global';
import type { FC, TeactNode } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMessageEntityTypes } from '../../api/types';
import { getPropertyHexColor } from '../../util/themeStyle'; import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme'; import { hexToRgb } from '../../util/switchTheme';
@ -19,6 +20,7 @@ import StickerView from './StickerView';
import styles from './CustomEmoji.module.scss'; import styles from './CustomEmoji.module.scss';
import svgPlaceholder from '../../assets/square.svg'; import svgPlaceholder from '../../assets/square.svg';
import blankImg from '../../assets/blank.png';
type OwnProps = { type OwnProps = {
ref?: React.RefObject<HTMLDivElement>; ref?: React.RefObject<HTMLDivElement>;
@ -121,8 +123,12 @@ const CustomEmoji: FC<OwnProps> = ({
withGridFix && styles.withGridFix, withGridFix && styles.withGridFix,
)} )}
onClick={onClick} onClick={onClick}
data-entity-type={ApiMessageEntityTypes.CustomEmoji}
data-document-id={documentId}
data-alt={customEmoji?.emoji}
style={style} style={style}
> >
<img className={styles.highlightCatch} src={blankImg} alt={customEmoji?.emoji} draggable={false} />
{!customEmoji ? ( {!customEmoji ? (
<img className={styles.thumb} src={svgPlaceholder} alt="Emoji" /> <img className={styles.thumb} src={svgPlaceholder} alt="Emoji" />
) : ( ) : (

View File

@ -1,8 +1,10 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useCallback } from '../../lib/teact/teact'; import React, { memo, useCallback } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions } from '../../global';
import convertPunycode from '../../lib/punycode'; import convertPunycode from '../../lib/punycode';
import type { FC } from '../../lib/teact/teact';
import { ApiMessageEntityTypes } from '../../api/types';
import { import {
DEBUG, DEBUG,
} from '../../config'; } from '../../config';
@ -55,6 +57,7 @@ const SafeLink: FC<OwnProps> = ({
className={classNames} className={classNames}
onClick={handleClick} onClick={handleClick}
dir={isRtl ? 'rtl' : 'auto'} dir={isRtl ? 'rtl' : 'auto'}
data-entity-type={ApiMessageEntityTypes.Url}
> >
{content} {content}
</a> </a>

View File

@ -1,6 +1,8 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useState } 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 buildClassName from '../../../util/buildClassName';
import useAsync from '../../../hooks/useAsync'; import useAsync from '../../../hooks/useAsync';
@ -36,7 +38,7 @@ const CodeBlock: FC<OwnProps> = ({ text, language, noCopy }) => {
const blockClass = buildClassName('code-block', !isWordWrap && 'no-word-wrap'); const blockClass = buildClassName('code-block', !isWordWrap && 'no-word-wrap');
return ( return (
<pre className={blockClass}> <pre className={blockClass} data-entity-type={ApiMessageEntityTypes.Pre} data-language={language}>
{highlighted} {highlighted}
<CodeOverlay <CodeOverlay
text={text} text={text}

View File

@ -23,6 +23,7 @@ export function renderMessageText(
truncateLength?: number, truncateLength?: number,
isProtected?: boolean, isProtected?: boolean,
observeIntersection?: ObserveFn, observeIntersection?: ObserveFn,
shouldRenderAsHtml?: boolean,
) { ) {
const { text, entities } = message.content.text || {}; const { text, entities } = message.content.text || {};
@ -36,7 +37,7 @@ export function renderMessageText(
entities, entities,
highlight, highlight,
emojiSize, emojiSize,
undefined, shouldRenderAsHtml,
message.id, message.id,
isSimple, isSimple,
isProtected, isProtected,

View File

@ -335,15 +335,16 @@ function processEntity(
switch (entity.type) { switch (entity.type) {
case ApiMessageEntityTypes.Bold: case ApiMessageEntityTypes.Bold:
return <strong>{renderNestedMessagePart()}</strong>; return <strong data-entity-type={entity.type}>{renderNestedMessagePart()}</strong>;
case ApiMessageEntityTypes.Blockquote: case ApiMessageEntityTypes.Blockquote:
return <blockquote>{renderNestedMessagePart()}</blockquote>; return <blockquote data-entity-type={entity.type}>{renderNestedMessagePart()}</blockquote>;
case ApiMessageEntityTypes.BotCommand: case ApiMessageEntityTypes.BotCommand:
return ( return (
<a <a
onClick={handleBotCommandClick} onClick={handleBotCommandClick}
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</a> </a>
@ -354,6 +355,7 @@ function processEntity(
onClick={handleHashtagClick} onClick={handleHashtagClick}
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</a> </a>
@ -364,6 +366,7 @@ function processEntity(
onClick={handleHashtagClick} onClick={handleHashtagClick}
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</a> </a>
@ -375,6 +378,7 @@ function processEntity(
onClick={!isProtected ? handleCodeClick : undefined} onClick={!isProtected ? handleCodeClick : undefined}
role="textbox" role="textbox"
tabIndex={0} tabIndex={0}
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</code> </code>
@ -387,12 +391,13 @@ function processEntity(
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</a> </a>
); );
case ApiMessageEntityTypes.Italic: case ApiMessageEntityTypes.Italic:
return <em>{renderNestedMessagePart()}</em>; return <em data-entity-type={entity.type}>{renderNestedMessagePart()}</em>;
case ApiMessageEntityTypes.MentionName: case ApiMessageEntityTypes.MentionName:
return ( return (
<MentionLink userId={entity.userId}> <MentionLink userId={entity.userId}>
@ -411,6 +416,7 @@ function processEntity(
href={`tel:${entityText}`} href={`tel:${entityText}`}
className="text-entity-link" className="text-entity-link"
dir="auto" dir="auto"
data-entity-type={entity.type}
> >
{renderNestedMessagePart()} {renderNestedMessagePart()}
</a> </a>
@ -418,7 +424,7 @@ function processEntity(
case ApiMessageEntityTypes.Pre: case ApiMessageEntityTypes.Pre:
return <CodeBlock text={entityText} language={entity.language} noCopy={isProtected} />; return <CodeBlock text={entityText} language={entity.language} noCopy={isProtected} />;
case ApiMessageEntityTypes.Strike: case ApiMessageEntityTypes.Strike:
return <del>{renderNestedMessagePart()}</del>; return <del data-entity-type={entity.type}>{renderNestedMessagePart()}</del>;
case ApiMessageEntityTypes.TextUrl: case ApiMessageEntityTypes.TextUrl:
case ApiMessageEntityTypes.Url: case ApiMessageEntityTypes.Url:
return ( return (
@ -430,7 +436,7 @@ function processEntity(
</SafeLink> </SafeLink>
); );
case ApiMessageEntityTypes.Underline: case ApiMessageEntityTypes.Underline:
return <ins>{renderNestedMessagePart()}</ins>; return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Spoiler: case ApiMessageEntityTypes.Spoiler:
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>; return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji: case ApiMessageEntityTypes.CustomEmoji:

View File

@ -1,9 +1,10 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import { ApiMessageEntityTypes } from '../../../api/types';
import { createClassNameBuilder } from '../../../util/buildClassName'; import { createClassNameBuilder } from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -34,7 +35,7 @@ const Spoiler: FC<OwnProps> = ({
const [isRevealed, reveal, conceal] = useFlag(); const [isRevealed, reveal, conceal] = useFlag();
const handleClick = useCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => { const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -74,6 +75,7 @@ const Spoiler: FC<OwnProps> = ({
!isRevealed && Boolean(messageId) && 'animated', !isRevealed && Boolean(messageId) && 'animated',
)} )}
onClick={messageId && !isRevealed ? handleClick : undefined} onClick={messageId && !isRevealed ? handleClick : undefined}
data-entity-type={ApiMessageEntityTypes.Spoiler}
> >
<span className={buildClassName('content')} ref={contentRef}> <span className={buildClassName('content')} ref={contentRef}>
{children} {children}

View File

@ -78,6 +78,7 @@ import { getServerTime } from '../../../util/serverTime';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import { buildCustomEmojiHtml } from './helpers/customEmoji'; import { buildCustomEmojiHtml } from './helpers/customEmoji';
import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager';
import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
@ -512,6 +513,13 @@ const Composer: FC<OwnProps & StateProps> = ({
}); });
}, [htmlRef, setHtml]); }, [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 insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html']) const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('') .join('')
@ -565,6 +573,24 @@ const Composer: FC<OwnProps & StateProps> = ({
}; };
}, [chatId, resetComposer, stopRecordingVoiceRef]); }, [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( const [handleEditComplete, handleEditCancel] = useEditing(
htmlRef, htmlRef,
setHtml, setHtml,
@ -578,7 +604,14 @@ const Composer: FC<OwnProps & StateProps> = ({
editingDraft, editingDraft,
); );
useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage, lastSyncTime); useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage, lastSyncTime);
useClipboardPaste(isForCurrentMessageList, insertTextAndUpdateCursor, handleSetAttachments, editingMessage); useClipboardPaste(
isForCurrentMessageList,
insertFormattedTextAndUpdateCursor,
handleSetAttachments,
editingMessage,
!isCurrentUserPremium && !isChatWithSelf,
showCustomEmojiPremiumNotification,
);
const handleEmbeddedClear = useCallback(() => { const handleEmbeddedClear = useCallback(() => {
if (editingMessage) { if (editingMessage) {
@ -775,29 +808,12 @@ const Composer: FC<OwnProps & StateProps> = ({
const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => {
if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) {
const notificationNumber = customEmojiNotificationNumber.current; showCustomEmojiPremiumNotification();
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);
return; return;
} }
insertCustomEmojiAndUpdateCursor(emoji); insertCustomEmojiAndUpdateCursor(emoji);
}, [ }, [insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, showCustomEmojiPremiumNotification]);
currentUserId, insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, lang,
openChat, openPremiumModal, showNotification,
]);
const handleStickerSelect = useCallback(( const handleStickerSelect = useCallback((
sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false,
@ -1396,7 +1412,7 @@ export default memo(withGlobal<OwnProps>(
const { currentUserId } = global; const { currentUserId } = global;
const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined; const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined;
const sendAsId = chat?.sendAsPeerIds && defaultSendAsId 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); : (chat?.adminRights?.anonymous ? chat?.id : undefined);
const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined;
const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined;

View File

@ -5,7 +5,6 @@ import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types'; import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import { import {
selectChat, selectChat,
@ -23,7 +22,7 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../global/helpers'; import { isUserId, stripCustomEmoji } from '../../../global/helpers';
import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
@ -164,18 +163,14 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
: undefined; : undefined;
const strippedMessage = useMemo(() => { const strippedMessage = useMemo(() => {
const textEntities = message?.content.text?.entities; if (!message || !isForwarding || !message.content.text || !noAuthors || isCurrentUserPremium) return message;
if (!message || !isForwarding || !textEntities?.length || !noAuthors || isCurrentUserPremium) return message;
const filteredEntities = textEntities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji); const strippedText = stripCustomEmoji(message.content.text);
return { return {
...message, ...message,
content: { content: {
...message.content, ...message.content,
text: { text: strippedText,
text: message.content.text!.text,
entities: filteredEntities,
},
}, },
}; };
}, [isCurrentUserPremium, isForwarding, message, noAuthors]); }, [isCurrentUserPremium, isForwarding, message, noAuthors]);

View File

@ -1,4 +1,5 @@
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types'; import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import { EMOJI_SIZES } from '../../../../config'; import { EMOJI_SIZES } from '../../../../config';
import { REM } from '../../../common/helpers/mediaDimensions'; import { REM } from '../../../common/helpers/mediaDimensions';
@ -16,6 +17,7 @@ export function buildCustomEmojiHtml(emoji: ApiSticker) {
draggable="false" draggable="false"
alt="${emoji.emoji}" alt="${emoji.emoji}"
data-document-id="${emoji.id}" data-document-id="${emoji.id}"
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
src="${mediaData || placeholderSrc}" src="${mediaData || placeholderSrc}"
/>`; />`;
} }
@ -28,6 +30,7 @@ export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessa
draggable="false" draggable="false"
alt="${rawText}" alt="${rawText}"
data-document-id="${entity.documentId}" data-document-id="${entity.documentId}"
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
src="${mediaData || placeholderSrc}" src="${mediaData || placeholderSrc}"
/>`; />`;
} }

View File

@ -1,19 +1,67 @@
import type { StateHookSetter } from '../../../../lib/teact/teact'; import type { StateHookSetter } from '../../../../lib/teact/teact';
import { useEffect } 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 buildAttachment from '../helpers/buildAttachment';
import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../../config'; import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../../config';
import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems'; 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 CLIPBOARD_ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
const MAX_MESSAGE_LENGTH = 4096; const MAX_MESSAGE_LENGTH = 4096;
const STYLE_TAG_REGEX = /<style>(.*?)<\/style>/gs;
function preparePastedHtml(html: string) {
let fragment = document.createElement('div');
fragment.innerHTML = html.replace(/\u00a0/g, ' ').replace(STYLE_TAG_REGEX, ''); // Strip &nbsp and styles
const textContents = fragment.querySelectorAll<HTMLDivElement>('.text-content');
if (textContents.length) {
fragment = textContents[textContents.length - 1]; // Replace with the last copied message
}
Array.from(fragment.getElementsByTagName('*')).forEach((node) => {
if (!(node instanceof HTMLElement)) return;
node.removeAttribute('style');
// Fix newlines
if (node.tagName === 'BR') node.replaceWith('\n');
if (node.tagName === 'P') node.appendChild(document.createTextNode('\n'));
if (node.tagName === 'IMG' && !node.dataset.entityType) node.replaceWith(node.getAttribute('alt') || '');
// We do not intercept copy logic, so we remove some nodes here
if (node.dataset.ignoreOnPaste) node.remove();
if (ENTITY_CLASS_BY_NODE_NAME[node.tagName]) {
node.setAttribute('data-entity-type', ENTITY_CLASS_BY_NODE_NAME[node.tagName]);
}
// Strip non-entity tags
if (!node.dataset.entityType && node.textContent === node.innerText) node.replaceWith(node.textContent);
// Append entity parameters for parsing
if (node.dataset.alt) node.setAttribute('alt', node.dataset.alt);
switch (node.dataset.entityType) {
case ApiMessageEntityTypes.MentionName:
node.replaceWith(node.textContent || '');
break;
case ApiMessageEntityTypes.CustomEmoji:
node.textContent = node.dataset.alt || '';
break;
}
});
return fragment.innerHTML.trimEnd();
}
const useClipboardPaste = ( const useClipboardPaste = (
isActive: boolean, isActive: boolean,
insertTextAndUpdateCursor: (text: string, inputId?: string) => void, insertTextAndUpdateCursor: (text: ApiFormattedText, inputId?: string) => void,
setAttachments: StateHookSetter<ApiAttachment[]>, setAttachments: StateHookSetter<ApiAttachment[]>,
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
shouldStripCustomEmoji?: boolean,
onCustomEmojiStripped?: VoidFunction,
) => { ) => {
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
@ -31,6 +79,16 @@ const useClipboardPaste = (
} }
const pastedText = e.clipboardData.getData('text').substring(0, MAX_MESSAGE_LENGTH); const pastedText = e.clipboardData.getData('text').substring(0, MAX_MESSAGE_LENGTH);
const html = e.clipboardData.getData('text/html');
let pastedFormattedText = html ? parseMessageInput(
preparePastedHtml(html), undefined, true,
) : undefined;
if (pastedFormattedText && containsCustomEmoji(pastedFormattedText) && shouldStripCustomEmoji) {
pastedFormattedText = stripCustomEmoji(pastedFormattedText);
onCustomEmojiStripped?.();
}
const { items } = e.clipboardData; const { items } = e.clipboardData;
let files: File[] = []; let files: File[] = [];
@ -50,8 +108,10 @@ const useClipboardPaste = (
setAttachments((attachments) => attachments.concat(newAttachments)); setAttachments((attachments) => attachments.concat(newAttachments));
} }
if (pastedText) { const textToPaste = pastedFormattedText?.entities?.length ? pastedFormattedText : { text: pastedText };
insertTextAndUpdateCursor(pastedText, input?.id);
if (textToPaste) {
insertTextAndUpdateCursor(textToPaste, input?.id);
} }
} }
@ -60,7 +120,9 @@ const useClipboardPaste = (
return () => { return () => {
document.removeEventListener('paste', handlePaste, false); document.removeEventListener('paste', handlePaste, false);
}; };
}, [insertTextAndUpdateCursor, editedMessage, setAttachments, isActive]); }, [
insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, onCustomEmojiStripped,
]);
}; };
export default useClipboardPaste; export default useClipboardPaste;

View File

@ -38,7 +38,7 @@ const BaseResult: FC<OwnProps> = ({
if (thumbUrl) { if (thumbUrl) {
content = ( content = (
<img src={thumbUrl} className={transitionClassNames} alt="" decoding="async" draggable="false" /> <img src={thumbUrl} className={transitionClassNames} alt="" decoding="async" draggable={false} />
); );
} else if (title) { } else if (title) {
content = getFirstLetters(title, 1); content = getFirstLetters(title, 1);

View File

@ -3,6 +3,7 @@ import React from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiUser } from '../../../api/types'; import type { ApiChat, ApiUser } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import { selectUser } from '../../../global/selectors'; import { selectUser } from '../../../global/selectors';
@ -17,6 +18,7 @@ type StateProps = {
}; };
const MentionLink: FC<OwnProps & StateProps> = ({ const MentionLink: FC<OwnProps & StateProps> = ({
userId,
username, username,
userOrChat, userOrChat,
children, children,
@ -35,7 +37,12 @@ const MentionLink: FC<OwnProps & StateProps> = ({
}; };
return ( return (
<a onClick={handleClick} className="text-entity-link" dir="auto"> <a
onClick={handleClick}
className="text-entity-link"
dir="auto"
data-entity-type={userId ? ApiMessageEntityTypes.MentionName : ApiMessageEntityTypes.Mention}
>
{children} {children}
</a> </a>
); );

View File

@ -856,10 +856,10 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
{!hasAnimatedEmoji && textParts && ( {!hasAnimatedEmoji && textParts && (
<p className={textContentClass} dir="auto"> <div className={textContentClass} dir="auto">
{textParts} {textParts}
{metaPosition === 'in-text' && renderReactionsAndMeta()} {metaPosition === 'in-text' && renderReactionsAndMeta()}
</p> </div>
)} )}
{webPage && ( {webPage && (

View File

@ -63,6 +63,7 @@ const MessageMeta: FC<OwnProps> = ({
className={buildClassName('MessageMeta', withReactionOffset && 'reactions-offset')} className={buildClassName('MessageMeta', withReactionOffset && 'reactions-offset')}
dir={lang.isRtl ? 'rtl' : 'ltr'} dir={lang.isRtl ? 'rtl' : 'ltr'}
onClick={onClick} onClick={onClick}
data-ignore-on-paste
> >
{reactions && reactions.map((l) => ( {reactions && reactions.map((l) => (
<ReactionAnimatedEmoji <ReactionAnimatedEmoji

View File

@ -121,7 +121,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
{channel && renderText(message.chatInviteTitle || getChatTitle(lang, channel, bot) || '')} {channel && renderText(message.chatInviteTitle || getChatTitle(lang, channel, bot) || '')}
</div> </div>
<p className="text-content with-meta" dir="auto" ref={contentRef}> <div className="text-content with-meta" dir="auto" ref={contentRef}>
<span className="text-content-inner" dir="auto"> <span className="text-content-inner" dir="auto">
{renderTextWithEntities(message.text.text, message.text.entities)} {renderTextWithEntities(message.text.text, message.text.entities)}
</span> </span>
@ -131,7 +131,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
{message.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')} {message.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
</span> </span>
</span> </span>
</p> </div>
<Button color="secondary" size="tiny" ripple onClick={handleClick} className="SponsoredMessage__button"> <Button color="secondary" size="tiny" ripple onClick={handleClick} className="SponsoredMessage__button">
{lang(message.isBot {lang(message.isBot

View File

@ -12,8 +12,13 @@ import {
getMessageWebPageVideo, getMessageWebPageVideo,
hasMessageLocalBlobUrl, hasMessageLocalBlobUrl,
} from '../../../../global/helpers'; } from '../../../../global/helpers';
import { CLIPBOARD_ITEM_SUPPORTED, copyImageToClipboard, copyTextToClipboard } from '../../../../util/clipboard'; import {
CLIPBOARD_ITEM_SUPPORTED,
copyHtmlToClipboard,
copyImageToClipboard,
} from '../../../../util/clipboard';
import getMessageIdsForSelectedText from '../../../../util/getMessageIdsForSelectedText'; import getMessageIdsForSelectedText from '../../../../util/getMessageIdsForSelectedText';
import { renderMessageText } from '../../../common/helpers/renderMessageText';
type ICopyOptions = { type ICopyOptions = {
label: string; label: string;
@ -65,9 +70,13 @@ export function getMessageCopyOptions(
const messageIds = getMessageIdsForSelectedText(); const messageIds = getMessageIdsForSelectedText();
if (messageIds?.length && onCopyMessages) { if (messageIds?.length && onCopyMessages) {
onCopyMessages(messageIds); onCopyMessages(messageIds);
} else if (hasSelection) {
document.execCommand('copy');
} else { } else {
const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!; const clipboardText = renderMessageText(
copyTextToClipboard(clipboardText); message, undefined, undefined, undefined, undefined, undefined, undefined, true,
);
if (clipboardText) copyHtmlToClipboard(clipboardText.join(''), getMessageTextWithSpoilers(message)!);
} }
afterEffect?.(); afterEffect?.();

View File

@ -44,8 +44,9 @@ import versionNotification from '../../../versionNotification.txt';
import parseMessageInput from '../../../util/parseMessageInput'; import parseMessageInput from '../../../util/parseMessageInput';
import { getMessageSummaryText, getSenderTitle } from '../../helpers'; import { getMessageSummaryText, getSenderTitle } from '../../helpers';
import * as langProvider from '../../../util/langProvider'; import * as langProvider from '../../../util/langProvider';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyHtmlToClipboard } from '../../../util/clipboard';
import type { GlobalState } from '../../types'; import type { GlobalState } from '../../types';
import { renderMessageSummaryHtml } from '../../helpers/renderMessageSummaryHtml';
const FOCUS_DURATION = 1500; const FOCUS_DURATION = 1500;
const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY; const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY;
@ -750,11 +751,21 @@ function copyTextForMessages(global: GlobalState, chatId: string, messageIds: nu
const result = messages.reduce((acc, message) => { const result = messages.reduce((acc, message) => {
const sender = selectSender(global, message); const sender = selectSender(global, message);
acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`);
acc.push(`${renderMessageSummaryHtml(lang, message)}\n`);
return acc;
}, [] as string[]);
const resultText = messages.reduce((acc, message) => {
const sender = selectSender(global, message);
acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`); acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`);
acc.push(`${getMessageSummaryText(lang, message, false, 0, undefined, true)}\n`); acc.push(`${getMessageSummaryText(lang, message, false, 0, undefined, true)}\n`);
return acc; return acc;
}, [] as string[]); }, [] as string[]);
copyTextToClipboard(result.join('\n')); copyHtmlToClipboard(result.join('\n'), resultText.join('\n'));
} }

View File

@ -0,0 +1,18 @@
import type { LangFn } from '../../hooks/useLang';
import type { ApiMessage } from '../../api/types';
import { renderMessageText } from '../../components/common/helpers/renderMessageText';
import { getMessageSummaryDescription, getMessageSummaryEmoji } from './messageSummary';
export function renderMessageSummaryHtml(
lang: LangFn,
message: ApiMessage,
) {
const emoji = getMessageSummaryEmoji(message);
const emojiWithSpace = emoji ? `${emoji} ` : '';
const text = renderMessageText(
message, undefined, undefined, undefined, undefined, undefined, undefined, true,
)?.join('');
const description = getMessageSummaryDescription(lang, message, text, true, true);
return `${emojiWithSpace}${description}`;
}

View File

@ -1,3 +1,17 @@
import type { ApiFormattedText } from '../../api/types';
import { ApiMessageEntityTypes } from '../../api/types';
export function getStickerPreviewHash(stickerId: string) { export function getStickerPreviewHash(stickerId: string) {
return `sticker${stickerId}?size=m`; return `sticker${stickerId}?size=m`;
} }
export function containsCustomEmoji(formattedText: ApiFormattedText) {
return formattedText.entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji);
}
export function stripCustomEmoji(text: ApiFormattedText): ApiFormattedText {
if (!text.entities) return text;
const entities = text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji);
return { ...text, entities };
}

View File

@ -27,6 +27,20 @@ export const copyTextToClipboard = (str: string): void => {
document.body.removeChild(textCopyEl); document.body.removeChild(textCopyEl);
}; };
export const copyHtmlToClipboard = (html: string, text: string): void => {
if (!window.navigator.clipboard?.write) {
copyTextToClipboard(text);
return;
}
window.navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([text], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' }),
}),
]);
};
export const copyImageToClipboard = (imageUrl?: string) => { export const copyImageToClipboard = (imageUrl?: string) => {
if (!imageUrl) return; if (!imageUrl) return;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');

View File

@ -2,7 +2,7 @@ import type { ApiMessageEntity, ApiFormattedText } from '../api/types';
import { ApiMessageEntityTypes } from '../api/types'; import { ApiMessageEntityTypes } from '../api/types';
import { RE_LINK_TEMPLATE } from '../config'; import { RE_LINK_TEMPLATE } from '../config';
const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = { export const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
B: ApiMessageEntityTypes.Bold, B: ApiMessageEntityTypes.Bold,
STRONG: ApiMessageEntityTypes.Bold, STRONG: ApiMessageEntityTypes.Bold,
I: ApiMessageEntityTypes.Italic, I: ApiMessageEntityTypes.Italic,
@ -18,16 +18,21 @@ const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
const MAX_TAG_DEEPNESS = 3; const MAX_TAG_DEEPNESS = 3;
export default function parseMessageInput(html: string, withMarkdownLinks = false): ApiFormattedText { export default function parseMessageInput(
html: string, withMarkdownLinks = false, skipMarkdown = false,
): ApiFormattedText {
const fragment = document.createElement('div'); const fragment = document.createElement('div');
fragment.innerHTML = withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html); fragment.innerHTML = skipMarkdown ? html
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
fixImageContent(fragment); fixImageContent(fragment);
const text = fragment.innerText.trim().replace(/\u200b+/g, ''); const text = fragment.innerText.trim().replace(/\u200b+/g, '');
let textIndex = 0; const trimShift = fragment.innerText.indexOf(text[0]);
let textIndex = -trimShift;
let recursionDeepness = 0; let recursionDeepness = 0;
const entities: ApiMessageEntity[] = []; const entities: ApiMessageEntity[] = [];
function addEntity(node: ChildNode) { function addEntity(node: ChildNode) {
if (node.nodeType === Node.COMMENT_NODE) return;
const { index, entity } = getEntityDataFromNode(node, text, textIndex); const { index, entity } = getEntityDataFromNode(node, text, textIndex);
if (entity) { if (entity) {
@ -208,6 +213,10 @@ function getEntityDataFromNode(
} }
function getEntityTypeFromNode(node: ChildNode): ApiMessageEntityTypes | undefined { function getEntityTypeFromNode(node: ChildNode): ApiMessageEntityTypes | undefined {
if (node instanceof HTMLElement && node.dataset.entityType) {
return node.dataset.entityType as ApiMessageEntityTypes;
}
if (ENTITY_CLASS_BY_NODE_NAME[node.nodeName]) { if (ENTITY_CLASS_BY_NODE_NAME[node.nodeName]) {
return ENTITY_CLASS_BY_NODE_NAME[node.nodeName]; return ENTITY_CLASS_BY_NODE_NAME[node.nodeName];
} }