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 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);

View File

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

View File

@ -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;
}

View File

@ -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" />
) : (

View File

@ -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>

View File

@ -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}

View File

@ -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,

View File

@ -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:

View File

@ -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}

View File

@ -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;

View File

@ -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]);

View File

@ -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}"
/>`;
}

View File

@ -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 &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 = (
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;

View File

@ -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);

View File

@ -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>
);

View File

@ -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 && (

View File

@ -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

View File

@ -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

View File

@ -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?.();

View File

@ -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'));
}

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) {
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);
};
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');

View File

@ -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];
}