Message, Composer: Support copy-paste formatting (#2096)
This commit is contained in:
parent
b69d4ee2f1
commit
0a5ea95c52
@ -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);
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import type {
|
||||
ApiAppConfig,
|
||||
ApiChat,
|
||||
ApiError,
|
||||
ApiLangString,
|
||||
ApiLanguage,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<HTMLDivElement>;
|
||||
@ -121,8 +123,12 @@ const CustomEmoji: FC<OwnProps> = ({
|
||||
withGridFix && styles.withGridFix,
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-entity-type={ApiMessageEntityTypes.CustomEmoji}
|
||||
data-document-id={documentId}
|
||||
data-alt={customEmoji?.emoji}
|
||||
style={style}
|
||||
>
|
||||
<img className={styles.highlightCatch} src={blankImg} alt={customEmoji?.emoji} draggable={false} />
|
||||
{!customEmoji ? (
|
||||
<img className={styles.thumb} src={svgPlaceholder} alt="Emoji" />
|
||||
) : (
|
||||
|
||||
@ -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<OwnProps> = ({
|
||||
className={classNames}
|
||||
onClick={handleClick}
|
||||
dir={isRtl ? 'rtl' : 'auto'}
|
||||
data-entity-type={ApiMessageEntityTypes.Url}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
|
||||
@ -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<OwnProps> = ({ text, language, noCopy }) => {
|
||||
const blockClass = buildClassName('code-block', !isWordWrap && 'no-word-wrap');
|
||||
|
||||
return (
|
||||
<pre className={blockClass}>
|
||||
<pre className={blockClass} data-entity-type={ApiMessageEntityTypes.Pre} data-language={language}>
|
||||
{highlighted}
|
||||
<CodeOverlay
|
||||
text={text}
|
||||
|
||||
@ -23,6 +23,7 @@ export function renderMessageText(
|
||||
truncateLength?: number,
|
||||
isProtected?: boolean,
|
||||
observeIntersection?: ObserveFn,
|
||||
shouldRenderAsHtml?: boolean,
|
||||
) {
|
||||
const { text, entities } = message.content.text || {};
|
||||
|
||||
@ -36,7 +37,7 @@ export function renderMessageText(
|
||||
entities,
|
||||
highlight,
|
||||
emojiSize,
|
||||
undefined,
|
||||
shouldRenderAsHtml,
|
||||
message.id,
|
||||
isSimple,
|
||||
isProtected,
|
||||
|
||||
@ -335,15 +335,16 @@ function processEntity(
|
||||
|
||||
switch (entity.type) {
|
||||
case ApiMessageEntityTypes.Bold:
|
||||
return <strong>{renderNestedMessagePart()}</strong>;
|
||||
return <strong data-entity-type={entity.type}>{renderNestedMessagePart()}</strong>;
|
||||
case ApiMessageEntityTypes.Blockquote:
|
||||
return <blockquote>{renderNestedMessagePart()}</blockquote>;
|
||||
return <blockquote data-entity-type={entity.type}>{renderNestedMessagePart()}</blockquote>;
|
||||
case ApiMessageEntityTypes.BotCommand:
|
||||
return (
|
||||
<a
|
||||
onClick={handleBotCommandClick}
|
||||
className="text-entity-link"
|
||||
dir="auto"
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</a>
|
||||
@ -354,6 +355,7 @@ function processEntity(
|
||||
onClick={handleHashtagClick}
|
||||
className="text-entity-link"
|
||||
dir="auto"
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</a>
|
||||
@ -364,6 +366,7 @@ function processEntity(
|
||||
onClick={handleHashtagClick}
|
||||
className="text-entity-link"
|
||||
dir="auto"
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</a>
|
||||
@ -375,6 +378,7 @@ function processEntity(
|
||||
onClick={!isProtected ? handleCodeClick : undefined}
|
||||
role="textbox"
|
||||
tabIndex={0}
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</code>
|
||||
@ -387,12 +391,13 @@ function processEntity(
|
||||
rel="noopener noreferrer"
|
||||
className="text-entity-link"
|
||||
dir="auto"
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</a>
|
||||
);
|
||||
case ApiMessageEntityTypes.Italic:
|
||||
return <em>{renderNestedMessagePart()}</em>;
|
||||
return <em data-entity-type={entity.type}>{renderNestedMessagePart()}</em>;
|
||||
case ApiMessageEntityTypes.MentionName:
|
||||
return (
|
||||
<MentionLink userId={entity.userId}>
|
||||
@ -411,6 +416,7 @@ function processEntity(
|
||||
href={`tel:${entityText}`}
|
||||
className="text-entity-link"
|
||||
dir="auto"
|
||||
data-entity-type={entity.type}
|
||||
>
|
||||
{renderNestedMessagePart()}
|
||||
</a>
|
||||
@ -418,7 +424,7 @@ function processEntity(
|
||||
case ApiMessageEntityTypes.Pre:
|
||||
return <CodeBlock text={entityText} language={entity.language} noCopy={isProtected} />;
|
||||
case ApiMessageEntityTypes.Strike:
|
||||
return <del>{renderNestedMessagePart()}</del>;
|
||||
return <del data-entity-type={entity.type}>{renderNestedMessagePart()}</del>;
|
||||
case ApiMessageEntityTypes.TextUrl:
|
||||
case ApiMessageEntityTypes.Url:
|
||||
return (
|
||||
@ -430,7 +436,7 @@ function processEntity(
|
||||
</SafeLink>
|
||||
);
|
||||
case ApiMessageEntityTypes.Underline:
|
||||
return <ins>{renderNestedMessagePart()}</ins>;
|
||||
return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
|
||||
case ApiMessageEntityTypes.Spoiler:
|
||||
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
|
||||
case ApiMessageEntityTypes.CustomEmoji:
|
||||
|
||||
@ -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<OwnProps> = ({
|
||||
|
||||
const [isRevealed, reveal, conceal] = useFlag();
|
||||
|
||||
const handleClick = useCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@ -74,6 +75,7 @@ const Spoiler: FC<OwnProps> = ({
|
||||
!isRevealed && Boolean(messageId) && 'animated',
|
||||
)}
|
||||
onClick={messageId && !isRevealed ? handleClick : undefined}
|
||||
data-entity-type={ApiMessageEntityTypes.Spoiler}
|
||||
>
|
||||
<span className={buildClassName('content')} ref={contentRef}>
|
||||
{children}
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
});
|
||||
}, [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<OwnProps & StateProps> = ({
|
||||
};
|
||||
}, [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<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
|
||||
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<OwnProps>(
|
||||
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;
|
||||
|
||||
@ -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<OwnProps & StateProps> = ({
|
||||
: 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]);
|
||||
|
||||
@ -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}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
@ -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 = /<style>(.*?)<\/style>/gs;
|
||||
|
||||
function preparePastedHtml(html: string) {
|
||||
let fragment = document.createElement('div');
|
||||
fragment.innerHTML = html.replace(/\u00a0/g, ' ').replace(STYLE_TAG_REGEX, ''); // Strip   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 = (
|
||||
isActive: boolean,
|
||||
insertTextAndUpdateCursor: (text: string, inputId?: string) => void,
|
||||
insertTextAndUpdateCursor: (text: ApiFormattedText, inputId?: string) => void,
|
||||
setAttachments: StateHookSetter<ApiAttachment[]>,
|
||||
editedMessage: ApiMessage | undefined,
|
||||
shouldStripCustomEmoji?: boolean,
|
||||
onCustomEmojiStripped?: VoidFunction,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
@ -31,6 +79,16 @@ const useClipboardPaste = (
|
||||
}
|
||||
|
||||
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;
|
||||
let files: File[] = [];
|
||||
|
||||
@ -50,8 +108,10 @@ const useClipboardPaste = (
|
||||
setAttachments((attachments) => attachments.concat(newAttachments));
|
||||
}
|
||||
|
||||
if (pastedText) {
|
||||
insertTextAndUpdateCursor(pastedText, input?.id);
|
||||
const textToPaste = pastedFormattedText?.entities?.length ? pastedFormattedText : { text: pastedText };
|
||||
|
||||
if (textToPaste) {
|
||||
insertTextAndUpdateCursor(textToPaste, input?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +120,9 @@ const useClipboardPaste = (
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste, false);
|
||||
};
|
||||
}, [insertTextAndUpdateCursor, editedMessage, setAttachments, isActive]);
|
||||
}, [
|
||||
insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, onCustomEmojiStripped,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useClipboardPaste;
|
||||
|
||||
@ -38,7 +38,7 @@ const BaseResult: FC<OwnProps> = ({
|
||||
|
||||
if (thumbUrl) {
|
||||
content = (
|
||||
<img src={thumbUrl} className={transitionClassNames} alt="" decoding="async" draggable="false" />
|
||||
<img src={thumbUrl} className={transitionClassNames} alt="" decoding="async" draggable={false} />
|
||||
);
|
||||
} else if (title) {
|
||||
content = getFirstLetters(title, 1);
|
||||
|
||||
@ -3,6 +3,7 @@ import React from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type { ApiChat, ApiUser } from '../../../api/types';
|
||||
import { ApiMessageEntityTypes } from '../../../api/types';
|
||||
|
||||
import { selectUser } from '../../../global/selectors';
|
||||
|
||||
@ -17,6 +18,7 @@ type StateProps = {
|
||||
};
|
||||
|
||||
const MentionLink: FC<OwnProps & StateProps> = ({
|
||||
userId,
|
||||
username,
|
||||
userOrChat,
|
||||
children,
|
||||
@ -35,7 +37,12 @@ const MentionLink: FC<OwnProps & StateProps> = ({
|
||||
};
|
||||
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
|
||||
@ -856,10 +856,10 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
)}
|
||||
|
||||
{!hasAnimatedEmoji && textParts && (
|
||||
<p className={textContentClass} dir="auto">
|
||||
<div className={textContentClass} dir="auto">
|
||||
{textParts}
|
||||
{metaPosition === 'in-text' && renderReactionsAndMeta()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webPage && (
|
||||
|
||||
@ -63,6 +63,7 @@ const MessageMeta: FC<OwnProps> = ({
|
||||
className={buildClassName('MessageMeta', withReactionOffset && 'reactions-offset')}
|
||||
dir={lang.isRtl ? 'rtl' : 'ltr'}
|
||||
onClick={onClick}
|
||||
data-ignore-on-paste
|
||||
>
|
||||
{reactions && reactions.map((l) => (
|
||||
<ReactionAnimatedEmoji
|
||||
|
||||
@ -121,7 +121,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
|
||||
{channel && renderText(message.chatInviteTitle || getChatTitle(lang, channel, bot) || '')}
|
||||
</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">
|
||||
{renderTextWithEntities(message.text.text, message.text.entities)}
|
||||
</span>
|
||||
@ -131,7 +131,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
|
||||
{message.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button color="secondary" size="tiny" ripple onClick={handleClick} className="SponsoredMessage__button">
|
||||
{lang(message.isBot
|
||||
|
||||
@ -12,8 +12,13 @@ import {
|
||||
getMessageWebPageVideo,
|
||||
hasMessageLocalBlobUrl,
|
||||
} 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 { renderMessageText } from '../../../common/helpers/renderMessageText';
|
||||
|
||||
type ICopyOptions = {
|
||||
label: string;
|
||||
@ -65,9 +70,13 @@ export function getMessageCopyOptions(
|
||||
const messageIds = getMessageIdsForSelectedText();
|
||||
if (messageIds?.length && onCopyMessages) {
|
||||
onCopyMessages(messageIds);
|
||||
} else if (hasSelection) {
|
||||
document.execCommand('copy');
|
||||
} else {
|
||||
const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!;
|
||||
copyTextToClipboard(clipboardText);
|
||||
const clipboardText = renderMessageText(
|
||||
message, undefined, undefined, undefined, undefined, undefined, undefined, true,
|
||||
);
|
||||
if (clipboardText) copyHtmlToClipboard(clipboardText.join(''), getMessageTextWithSpoilers(message)!);
|
||||
}
|
||||
|
||||
afterEffect?.();
|
||||
|
||||
@ -44,8 +44,9 @@ import versionNotification from '../../../versionNotification.txt';
|
||||
import parseMessageInput from '../../../util/parseMessageInput';
|
||||
import { getMessageSummaryText, getSenderTitle } from '../../helpers';
|
||||
import * as langProvider from '../../../util/langProvider';
|
||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||
import { copyHtmlToClipboard } from '../../../util/clipboard';
|
||||
import type { GlobalState } from '../../types';
|
||||
import { renderMessageSummaryHtml } from '../../helpers/renderMessageSummaryHtml';
|
||||
|
||||
const FOCUS_DURATION = 1500;
|
||||
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 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(`${getMessageSummaryText(lang, message, false, 0, undefined, true)}\n`);
|
||||
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
|
||||
copyTextToClipboard(result.join('\n'));
|
||||
copyHtmlToClipboard(result.join('\n'), resultText.join('\n'));
|
||||
}
|
||||
|
||||
18
src/global/helpers/renderMessageSummaryHtml.ts
Normal file
18
src/global/helpers/renderMessageSummaryHtml.ts
Normal 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}`;
|
||||
}
|
||||
@ -1,3 +1,17 @@
|
||||
import type { ApiFormattedText } from '../../api/types';
|
||||
import { ApiMessageEntityTypes } from '../../api/types';
|
||||
|
||||
export function getStickerPreviewHash(stickerId: string) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -27,6 +27,20 @@ export const copyTextToClipboard = (str: string): void => {
|
||||
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) => {
|
||||
if (!imageUrl) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ApiMessageEntity, ApiFormattedText } from '../api/types';
|
||||
import { ApiMessageEntityTypes } from '../api/types';
|
||||
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,
|
||||
STRONG: ApiMessageEntityTypes.Bold,
|
||||
I: ApiMessageEntityTypes.Italic,
|
||||
@ -18,16 +18,21 @@ const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
|
||||
|
||||
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');
|
||||
fragment.innerHTML = withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
|
||||
fragment.innerHTML = skipMarkdown ? html
|
||||
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
|
||||
fixImageContent(fragment);
|
||||
const text = fragment.innerText.trim().replace(/\u200b+/g, '');
|
||||
let textIndex = 0;
|
||||
const trimShift = fragment.innerText.indexOf(text[0]);
|
||||
let textIndex = -trimShift;
|
||||
let recursionDeepness = 0;
|
||||
const entities: ApiMessageEntity[] = [];
|
||||
|
||||
function addEntity(node: ChildNode) {
|
||||
if (node.nodeType === Node.COMMENT_NODE) return;
|
||||
const { index, entity } = getEntityDataFromNode(node, text, textIndex);
|
||||
|
||||
if (entity) {
|
||||
@ -208,6 +213,10 @@ function getEntityDataFromNode(
|
||||
}
|
||||
|
||||
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]) {
|
||||
return ENTITY_CLASS_BY_NODE_NAME[node.nodeName];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user