Message: Allow quote message (#4067)

This commit is contained in:
Alexander Zinchuk 2023-12-12 12:34:23 +01:00
parent df84448372
commit 89df119add
28 changed files with 345 additions and 136 deletions

View File

@ -886,6 +886,7 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep
replyToPeerId: inputInfo.replyToPeerId,
quoteText: inputInfo.quoteText,
isForumTopic: isForum && inputInfo.replyToTopId ? true : undefined,
...(Boolean(inputInfo.quoteText) && { isQuote: true }),
};
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" fill-rule="evenodd" d="M20 2.667a2.667 2.667 0 0 0-.306 5.316c-.141.498-.329.984-.562 1.45l-.32.64a1.328 1.328 0 0 0 2.376 1.188l.32-.64a10.928 10.928 0 0 0 1.153-4.887v-.23A2.667 2.667 0 0 0 20 2.666Zm6.667 0a2.667 2.667 0 0 0-.307 5.316c-.14.498-.328.984-.561 1.45l-.32.64a1.328 1.328 0 0 0 2.376 1.188l.32-.64a10.928 10.928 0 0 0 1.153-4.887v-.23a2.667 2.667 0 0 0-2.661-2.837ZM5.333 6.672a1.328 1.328 0 1 0 0 2.656H14a1.328 1.328 0 1 0 0-2.656H5.333Zm0 8a1.328 1.328 0 1 0 0 2.656h21.334a1.328 1.328 0 0 0 0-2.656H5.333ZM4.005 24c0-.733.595-1.328 1.328-1.328h21.334a1.328 1.328 0 1 1 0 2.656H5.333A1.328 1.328 0 0 1 4.005 24Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 752 B

View File

@ -85,7 +85,7 @@ import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dateF
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import focusEditableElement from '../../util/focusEditableElement';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import parseMessageInput from '../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../util/parseHtmlAsFormattedText';
import { insertHtmlInSelection } from '../../util/selection';
import { getServerTime } from '../../util/serverTime';
import { IS_IOS, IS_VOICE_RECORDING_SUPPORTED } from '../../util/windowEnvironment';
@ -869,7 +869,7 @@ const Composer: FC<OwnProps & StateProps> = ({
return;
}
const { text, entities } = parseMessageInput(getHtml());
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (!text && !attachmentsToSend.length) {
return;
}
@ -931,7 +931,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}
}
const { text, entities } = parseMessageInput(getHtml());
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (currentAttachments.length) {
sendAttachments({
@ -1398,7 +1398,7 @@ const Composer: FC<OwnProps & StateProps> = ({
showCustomEmojiPremiumNotification();
return;
}
const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker));
const customEmojiMessage = parseHtmlAsFormattedText(buildCustomEmojiHtml(sticker));
text = customEmojiMessage.text;
entities = customEmojiMessage.entities;
}

View File

@ -127,7 +127,14 @@ const CustomEmoji: FC<OwnProps> = ({
data-alt={customEmoji?.emoji}
style={style}
>
<img className={styles.highlightCatch} src={blankImg} alt={customEmoji?.emoji} draggable={false} />
<img
className={styles.highlightCatch}
src={blankImg}
alt={customEmoji?.emoji}
data-entity-type={ApiMessageEntityTypes.CustomEmoji}
data-document-id={documentId}
draggable={false}
/>
{!customEmoji ? (
<img className={styles.thumb} src={svgPlaceholder} alt="Emoji" draggable={false} />
) : (

View File

@ -51,6 +51,7 @@ type OwnProps = {
customText?: string;
noUserColors?: boolean;
isProtected?: boolean;
isInComposer?: boolean;
chatTranslations?: ChatTranslatedMessages;
requestedChatTranslationLanguage?: string;
observeIntersectionForLoading?: ObserveFn;
@ -71,6 +72,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
title,
customText,
isProtected,
isInComposer,
noUserColors,
chatTranslations,
requestedChatTranslationLanguage,
@ -118,6 +120,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
return renderTextWithEntities({
text: replyInfo.quoteText.text,
entities: replyInfo.quoteText.entities,
noLineBreaks: isInComposer,
});
}
@ -173,7 +176,11 @@ const EmbeddedMessage: FC<OwnProps> = ({
return (
<>
{!isChatSender && <span className="embedded-sender">{renderText(senderTitle)}</span>}
{!isChatSender && (
<span className="embedded-sender">
{renderText(isInComposer ? lang('ReplyToQuote', senderTitle) : senderTitle)}
</span>
)}
{icon && <Icon name={icon} className="embedded-chat-icon" />}
{icon && senderChatTitle && renderText(senderChatTitle)}
</>

View File

@ -37,6 +37,7 @@ export function renderTextWithEntities({
containerId,
isSimple,
isProtected,
noLineBreaks,
observeIntersectionForLoading,
observeIntersectionForPlaying,
withTranslucentThumbs,
@ -54,6 +55,7 @@ export function renderTextWithEntities({
containerId?: string;
isSimple?: boolean;
isProtected?: boolean;
noLineBreaks?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
withTranslucentThumbs?: boolean;
@ -64,7 +66,15 @@ export function renderTextWithEntities({
focusedQuote?: string;
}) {
if (!entities?.length) {
return renderMessagePart(text, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple);
return renderMessagePart({
content: text,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
isSimple,
noLineBreaks,
});
}
const result: TextPart[] = [];
@ -92,9 +102,15 @@ export function renderTextWithEntities({
deleteLineBreakAfterPre = false;
}
if (textBefore) {
renderResult.push(...renderMessagePart(
textBefore, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple,
) as TextPart[]);
renderResult.push(...renderMessagePart({
content: textBefore,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
isSimple,
noLineBreaks,
}) as TextPart[]);
}
}
@ -141,8 +157,10 @@ export function renderTextWithEntities({
entityContent,
nestedEntityContent,
highlight,
focusedQuote,
containerId,
isSimple,
noLineBreaks,
isProtected,
observeIntersectionForLoading,
observeIntersectionForPlaying,
@ -168,9 +186,15 @@ export function renderTextWithEntities({
textAfter = textAfter.substring(1);
}
if (textAfter) {
renderResult.push(...renderMessagePart(
textAfter, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple,
) as TextPart[]);
renderResult.push(...renderMessagePart({
content: textAfter,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
isSimple,
noLineBreaks,
}) as TextPart[]);
}
}
@ -217,19 +241,36 @@ export function getTextWithEntitiesAsHtml(formattedText?: ApiFormattedText) {
return result;
}
function renderMessagePart(
content: TextPart | TextPart[],
highlight?: string,
focusedQuote?: string,
emojiSize?: number,
shouldRenderAsHtml?: boolean,
isSimple?: boolean,
) {
function renderMessagePart({
content,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
isSimple,
noLineBreaks,
} : {
content: TextPart | TextPart[];
highlight?: string;
focusedQuote?: string;
emojiSize?: number;
shouldRenderAsHtml?: boolean;
isSimple?: boolean;
noLineBreaks?: boolean;
}) {
if (Array.isArray(content)) {
const result: TextPart[] = [];
content.forEach((c) => {
result.push(...renderMessagePart(c, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple));
result.push(...renderMessagePart({
content: c,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
isSimple,
noLineBreaks,
}));
});
return result;
@ -243,7 +284,7 @@ function renderMessagePart(
const filters: TextFilter[] = [emojiFilter];
const params: RenderTextParams = {};
if (!isSimple) {
if (!isSimple && !noLineBreaks) {
filters.push('br');
}
@ -330,8 +371,10 @@ function processEntity({
entityContent,
nestedEntityContent,
highlight,
focusedQuote,
containerId,
isSimple,
noLineBreaks,
isProtected,
observeIntersectionForLoading,
observeIntersectionForPlaying,
@ -346,8 +389,10 @@ function processEntity({
entityContent: TextPart;
nestedEntityContent: TextPart[];
highlight?: string;
focusedQuote?: string;
containerId?: string;
isSimple?: boolean;
noLineBreaks?: boolean;
isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
@ -362,9 +407,14 @@ function processEntity({
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
function renderNestedMessagePart() {
return renderMessagePart(
renderedContent, highlight, undefined, emojiSize, undefined, isSimple,
);
return renderMessagePart({
content: renderedContent,
highlight,
focusedQuote,
emojiSize,
isSimple,
noLineBreaks,
});
}
if (!entityText) {
@ -402,11 +452,11 @@ function processEntity({
return <strong data-entity-type={entity.type}>{renderNestedMessagePart()}</strong>;
case ApiMessageEntityTypes.Blockquote:
return (
<div className="text-entity-blockquote-wrapper">
<span className="text-entity-blockquote-wrapper">
<blockquote data-entity-type={entity.type}>
{renderNestedMessagePart()}
</blockquote>
</div>
</span>
);
case ApiMessageEntityTypes.BotCommand:
return (
@ -585,10 +635,10 @@ function processEntityAsHtml(
case ApiMessageEntityTypes.CustomEmoji:
return buildCustomEmojiHtmlFromEntity(rawEntityText, entity);
case ApiMessageEntityTypes.Blockquote:
return `<span
return `<blockquote
class="blockquote"
data-entity-type="${ApiMessageEntityTypes.Blockquote}"
>${renderedContent}</span>`;
>${renderedContent}</blockquote>`;
default:
return renderedContent;
}

View File

@ -23,6 +23,7 @@
}
& .embedded-left-icon {
position: relative;
flex-shrink: 0;
background: none !important;
height: 2.625rem;
@ -63,4 +64,15 @@
width: 1.5rem;
}
}
.quote-reply {
position: absolute;
right: 0.75rem;
top: 0.5rem;
font-size: 0.5rem;
@media (max-width: 600px) {
right: 0.625rem;
}
}
}

View File

@ -34,6 +34,7 @@ import useShowTransition from '../../../hooks/useShowTransition';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import EmbeddedMessage from '../../common/embedded/EmbeddedMessage';
import Icon from '../../common/Icon';
import Button from '../../ui/Button';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
@ -173,13 +174,13 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
const leftIcon = useMemo(() => {
if (isShowingReply) {
return 'icon-reply';
return 'reply';
}
if (editingId) {
return 'icon-edit';
return 'edit';
}
if (isForwarding) {
return 'icon-forward';
return 'forward';
}
return undefined;
@ -210,11 +211,15 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
<div className={className} ref={ref} onContextMenu={handleContextMenu} onClick={handleContextMenu}>
<div className={innerClassName}>
<div className="embedded-left-icon">
<i className={buildClassName('icon', leftIcon)} />
{leftIcon && <Icon name={leftIcon} />}
{Boolean(replyInfo?.quoteText) && (
<Icon name="quote" className="quote-reply" />
)}
</div>
<EmbeddedMessage
className="inside-input"
replyInfo={replyInfo}
isInComposer
message={strippedMessage}
sender={!noAuthors ? sender : undefined}
customText={customText}
@ -339,7 +344,7 @@ export default memo(withGlobal<OwnProps>(
sender = selectForwardedSender(global, message);
}
if (!sender && !forwardInfo?.hiddenUserName) {
if (!sender && (!forwardInfo?.hiddenUserName || Boolean(replyInfo.quoteText))) {
sender = selectSender(global, message);
}
} else if (isForwarding) {
@ -352,8 +357,8 @@ export default memo(withGlobal<OwnProps>(
if (!sender) {
sender = selectPeer(global, fromChatId!);
}
} else if (editingId) {
sender = selectSender(global, message!);
} else if (editingId && message) {
sender = selectSender(global, message);
}
const forwardsHaveCaptions = forwardedMessages?.some((forward) => (

View File

@ -8,7 +8,7 @@ import type { ApiNewPoll } from '../../../api/types';
import { requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import parseMessageInput from '../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
@ -142,7 +142,8 @@ const PollModal: FC<OwnProps> = ({
};
if (isQuizMode) {
const { text, entities } = (solution && parseMessageInput(solution.substring(0, MAX_SOLUTION_LENGTH))) || {};
const { text, entities } = (solution && parseHtmlAsFormattedText(solution.substring(0, MAX_SOLUTION_LENGTH)))
|| {};
payload.quiz = {
correctAnswers: [String(correctOption)],

View File

@ -12,7 +12,7 @@ import { ApiMessageEntityTypes } from '../../../api/types';
import { RE_LINK_TEMPLATE } from '../../../config';
import { selectNoWebPage, selectTabState, selectTheme } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import parseMessageInput from '../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
@ -61,7 +61,7 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
const formattedTextWithLinkRef = useRef<ApiFormattedText>();
const detectLinkDebounced = useDebouncedResolver(() => {
const formattedText = parseMessageInput(getHtml());
const formattedText = parseHtmlAsFormattedText(getHtml());
const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => (
entity.type === ApiMessageEntityTypes.TextUrl
));

View File

@ -2,7 +2,7 @@ import { ApiMessageEntityTypes } from '../../../../api/types';
import { DEBUG } from '../../../../config';
import cleanDocsHtml from '../../../../lib/cleanDocsHtml';
import { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseMessageInput';
import { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseHtmlAsFormattedText';
const STYLE_TAG_REGEX = /<style>(.*?)<\/style>/gs;

View File

@ -1,4 +1,4 @@
import { fixImageContent } from '../../../../util/parseMessageInput';
import { fixImageContent } from '../../../../util/parseHtmlAsFormattedText';
const div = document.createElement('div');

View File

@ -7,7 +7,7 @@ import {
EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID,
} from '../../../../config';
import { containsCustomEmoji, stripCustomEmoji } from '../../../../global/helpers/symbols';
import parseMessageInput from '../../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
import buildAttachment from '../helpers/buildAttachment';
import { preparePastedHtml } from '../helpers/cleanHtml';
import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems';
@ -45,7 +45,7 @@ const useClipboardPaste = (
const pastedText = e.clipboardData.getData('text').substring(0, MAX_MESSAGE_LENGTH);
const html = e.clipboardData.getData('text/html');
let pastedFormattedText = html ? parseMessageInput(
let pastedFormattedText = html ? parseHtmlAsFormattedText(
preparePastedHtml(html), undefined, true,
) : undefined;

View File

@ -11,7 +11,7 @@ import {
requestMeasure, requestNextMutation,
} from '../../../../lib/fasterdom/fasterdom';
import focusEditableElement from '../../../../util/focusEditableElement';
import parseMessageInput from '../../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
import { IS_TOUCH_ENV } from '../../../../util/windowEnvironment';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities';
@ -77,7 +77,7 @@ const useDraft = ({
saveDraft({
chatId: prevState.chatId ?? chatId,
threadId: prevState.threadId ?? threadId,
text: parseMessageInput(html),
text: parseHtmlAsFormattedText(html),
});
} else {
clearDraft({

View File

@ -10,7 +10,7 @@ import { EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config';
import { requestMeasure, requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { hasMessageMedia } from '../../../../global/helpers';
import focusEditableElement from '../../../../util/focusEditableElement';
import parseMessageInput from '../../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities';
import { useDebouncedResolver } from '../../../../hooks/useAsyncResolvers';
@ -87,7 +87,7 @@ const useEditing = (
useEffect(() => {
if (!editedMessage) return undefined;
return () => {
const edited = parseMessageInput(getHtml());
const edited = parseHtmlAsFormattedText(getHtml());
const update = edited.text.length ? edited : undefined;
setEditingDraft({
@ -99,7 +99,7 @@ const useEditing = (
const detectLinkDebounced = useDebouncedResolver(() => {
if (!editedMessage) return false;
const edited = parseMessageInput(getHtml());
const edited = parseHtmlAsFormattedText(getHtml());
return !('webPage' in editedMessage.content)
&& editedMessage.content.text?.entities?.some((entity) => URL_ENTITIES.has(entity.type))
&& !(edited.entities?.some((entity) => URL_ENTITIES.has(entity.type)));
@ -144,7 +144,7 @@ const useEditing = (
});
const handleEditComplete = useLastCallback(() => {
const { text, entities } = parseMessageInput(getHtml());
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (!editedMessage) {
return;
@ -167,7 +167,7 @@ const useEditing = (
const handleBlur = useLastCallback(() => {
if (!editedMessage) return;
const edited = parseMessageInput(getHtml());
const edited = parseHtmlAsFormattedText(getHtml());
const update = edited.text.length ? edited : undefined;
setEditingDraft({

View File

@ -43,6 +43,8 @@ import {
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
import { getSelectionAsFormattedText } from './helpers/getSelectionAsFormattedText';
import { isSelectionRangeInsideMessage } from './helpers/isSelectionRangeInsideMessage';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
@ -93,6 +95,7 @@ type StateProps = {
canCopy?: boolean;
canTranslate?: boolean;
canShowOriginal?: boolean;
isMessageTranslated?: boolean;
canSelectLanguage?: boolean;
isPrivate?: boolean;
isCurrentUserPremium?: boolean;
@ -113,6 +116,8 @@ type StateProps = {
messageLink?: string;
};
const selection = window.getSelection();
const ContextMenuContainer: FC<OwnProps & StateProps> = ({
availableReactions,
topReactions,
@ -158,6 +163,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canShowSeenBy,
canScheduleUntilOnline,
canTranslate,
isMessageTranslated,
canShowOriginal,
canSelectLanguage,
isReactionPickerOpen,
@ -202,7 +208,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
const [canQuoteSelection, setCanQuoteSelection] = useState(false);
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, onClose, message.date);
// `undefined` indicates that emoji are present and loading
@ -265,6 +271,35 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
return activeDownloads?.[message.isScheduled ? 'scheduledIds' : 'ids']?.includes(message.id);
}, [activeDownloads, album, message]);
const selectionRange = canReply && selection?.rangeCount ? selection.getRangeAt(0) : undefined;
useEffect(() => {
if (isMessageTranslated) {
setCanQuoteSelection(false);
return;
}
const isMessageTextSelected = selectionRange
&& !selectionRange.collapsed
&& Boolean(message.content.text?.text)
&& isSelectionRangeInsideMessage(selectionRange);
if (!isMessageTextSelected) {
setCanQuoteSelection(false);
return;
}
const selectionText = getSelectionAsFormattedText(selectionRange);
setCanQuoteSelection(
selectionText.text.trim().length > 0
&& message.content.text!.text!.includes(selectionText.text),
);
}, [
selectionRange, selectionRange?.collapsed, selectionRange?.startOffset, selectionRange?.endOffset,
isMessageTranslated, message.content.text,
]);
const handleDelete = useLastCallback(() => {
setIsMenuOpen(false);
setIsDeleteModalOpen(true);
@ -296,7 +331,10 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
});
const handleReply = useLastCallback(() => {
updateDraftReplyInfo({ replyToMsgId: message.id });
updateDraftReplyInfo({
replyToMsgId: message.id,
quoteText: canQuoteSelection && selectionRange ? getSelectionAsFormattedText(selectionRange) : undefined,
});
closeMenu();
});
@ -493,6 +531,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canSendNow={canSendNow}
canReschedule={canReschedule}
canReply={canReply}
canQuote={canQuoteSelection}
canDelete={canDelete}
canReport={canReport}
canPin={canPin}
@ -679,6 +718,7 @@ export default memo(withGlobal<OwnProps>(
canTranslate,
canShowOriginal: hasTranslation && !isChatTranslated,
canSelectLanguage: hasTranslation && !isChatTranslated,
isMessageTranslated: hasTranslation,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global),
messageLink,

View File

@ -95,6 +95,7 @@ import {
import { isAnimatingScroll } from '../../../util/animateScroll';
import buildClassName from '../../../util/buildClassName';
import { isElementInViewport } from '../../../util/isElementInViewport';
import stopEvent from '../../../util/stopEvent';
import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment';
import {
calculateDimensionsForMessageMedia,
@ -522,6 +523,7 @@ const Message: FC<OwnProps & StateProps> = ({
const avatarPeer = shouldPreferOriginSender ? originSender : messageSender;
const messageColorPeer = originSender || sender;
const senderPeer = (forwardInfo || message.content.storyData) ? originSender : messageSender;
const hasText = hasMessageText(message);
const {
handleMouseDown,
@ -605,7 +607,7 @@ const Message: FC<OwnProps & StateProps> = ({
const containerClassName = buildClassName(
'Message message-list-item',
isFirstInGroup && 'first-in-group',
isProtected ? 'is-protected' : 'allow-selection',
isProtected && !hasText ? 'is-protected' : 'allow-selection',
isLastInGroup && 'last-in-group',
isFirstInDocumentGroup && 'first-in-document-group',
isLastInDocumentGroup && 'last-in-document-group',
@ -686,7 +688,6 @@ const Message: FC<OwnProps & StateProps> = ({
});
const withAppendix = contentClassName.includes('has-appendix');
const hasText = hasMessageText(message);
const emojiSize = getCustomEmojiSize(message.emojiOnlyCount);
let metaPosition!: MetaPosition;
@ -1319,6 +1320,7 @@ const Message: FC<OwnProps & StateProps> = ({
id={getMessageHtmlId(message.id)}
className={containerClassName}
data-message-id={messageId}
onCopy={isProtected ? stopEvent : undefined}
onMouseDown={handleMouseDown}
onClick={handleClick}
onContextMenu={handleContextMenu}

View File

@ -53,6 +53,7 @@ type OwnProps = {
maxUniqueReactions?: number;
canReschedule?: boolean;
canReply?: boolean;
canQuote?: boolean;
repliesThreadInfo?: ApiThreadInfo;
canPin?: boolean;
canUnpin?: boolean;
@ -140,6 +141,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canReschedule,
canBuyPremium,
canReply,
canQuote,
canEdit,
noReplies,
canPin,
@ -361,7 +363,11 @@ const MessageContextMenu: FC<OwnProps> = ({
{canReschedule && (
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
)}
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
{canReply && (
<MenuItem icon="reply" onClick={onReply}>
{lang(canQuote ? 'lng_context_quote_and_reply' : 'Reply')}
</MenuItem>
)}
{!noReplies && Boolean(repliesThreadInfo?.messagesCount) && (
<MenuItem icon="replies" onClick={onOpenThread}>
{lang('Conversation.ContextViewReplies', repliesThreadInfo!.messagesCount, 'i')}

View File

@ -14,7 +14,7 @@ import {
selectPeerStory, selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import parseMessageInput from '../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { REM } from '../../common/helpers/mediaDimensions';
import { buildCustomEmojiHtml } from '../composer/helpers/customEmoji';
@ -162,7 +162,7 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
if ('emoticon' in item) {
text = item.emoticon;
} else {
const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker!));
const customEmojiMessage = parseHtmlAsFormattedText(buildCustomEmojiHtml(sticker!));
text = customEmojiMessage.text;
entities = customEmojiMessage.entities;
}

View File

@ -953,6 +953,12 @@
font-size: 0.875rem;
}
// Remove extra bottom padding from `blockquote`
.text-entity-blockquote-wrapper {
display: inline-block;
width: 100%
}
blockquote, .blockquote {
display: inline-block;
position: relative;

View File

@ -0,0 +1,57 @@
import type { ApiFormattedText } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
const div = document.createElement('div');
const ALLOWED_QUOTE_ENTITIES = new Set([
ApiMessageEntityTypes.Bold,
ApiMessageEntityTypes.Italic,
ApiMessageEntityTypes.Underline,
ApiMessageEntityTypes.Strike,
ApiMessageEntityTypes.Spoiler,
ApiMessageEntityTypes.CustomEmoji,
]);
export function getSelectionAsFormattedText(range: Range) {
const html = getSelectionAsHtml(range);
const formattedText = parseHtmlAsFormattedText(html, false, true);
return stripEntitiesForQuote(formattedText);
}
function getSelectionAsHtml(range: Range) {
const clonedSelection = range.cloneContents();
div.appendChild(clonedSelection);
const html = wrapHtmlWithMarkupTags(range, div.innerHTML);
div.innerHTML = '';
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/gi, ' ') // Convert nbsp's to spaces
.replace(/\u00a0/gi, ' ');
}
function stripEntitiesForQuote(text: ApiFormattedText): ApiFormattedText {
if (!text.entities) return text;
const entities = text.entities.filter((entity) => ALLOWED_QUOTE_ENTITIES.has(entity.type as ApiMessageEntityTypes));
return { ...text, entities: entities.length ? entities : undefined };
}
function wrapHtmlWithMarkupTags(range: Range, html: string) {
const container = range.commonAncestorContainer;
if (container.nodeType === Node.ELEMENT_NODE && (container as Element).classList.contains('text-content')) {
return html;
}
let currentElement = range.commonAncestorContainer.parentElement;
while (currentElement && !currentElement.classList.contains('text-content')) {
const tag = currentElement.tagName.toLowerCase();
const entityType = currentElement.dataset.entityType;
html = `<${tag} ${entityType ? `data-entity-type="${entityType}"` : ''}>${html}</${tag}>`;
currentElement = currentElement.parentElement;
}
return html;
}

View File

@ -0,0 +1,9 @@
export function isSelectionRangeInsideMessage(range: Range) {
const ancestor = range.commonAncestorContainer;
const el = ancestor.nodeType === Node.TEXT_NODE
? ancestor.parentNode! as Element
: ancestor as Element;
return Boolean(el.closest('.message-content-wrapper .text-content'))
&& !(Boolean(el.closest('.EmbeddedMessage')) || Boolean(el.closest('.WebPage')));
}

View File

@ -16,7 +16,7 @@ import { copyHtmlToClipboard } from '../../../util/clipboard';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { compact, findLast } from '../../../util/iteratees';
import * as langProvider from '../../../util/langProvider';
import parseMessageInput from '../../../util/parseMessageInput';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { getServerTime } from '../../../util/serverTime';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import versionNotification from '../../../versionNotification.txt';
@ -689,7 +689,7 @@ addActionHandler('checkVersionNotification', (global, actions): ActionReturnType
chatId: SERVICE_NOTIFICATIONS_USER_ID,
date: getServerTime(),
content: {
text: parseMessageInput(versionNotification, true),
text: parseHtmlAsFormattedText(versionNotification, true),
},
isOutgoing: false,
};

View File

@ -175,80 +175,81 @@ $icons-map: (
"premium": "\f190",
"previous": "\f191",
"privacy-policy": "\f192",
"quote": "\f193",
"readchats": "\f194",
"recent": "\f195",
"reload": "\f196",
"remove": "\f197",
"reopen-topic": "\f198",
"replace": "\f199",
"replies": "\f19a",
"reply-filled": "\f19b",
"reply": "\f19c",
"revote": "\f19d",
"save-story": "\f19e",
"saved-messages": "\f19f",
"schedule": "\f1a0",
"search": "\f1a1",
"select": "\f1a2",
"send-outline": "\f1a3",
"send": "\f1a4",
"settings-filled": "\f1a5",
"settings": "\f1a6",
"share-filled": "\f1a7",
"share-screen-outlined": "\f1a8",
"share-screen-stop": "\f1a9",
"share-screen": "\f1aa",
"sidebar": "\f1ab",
"skip-next": "\f1ac",
"skip-previous": "\f1ad",
"smallscreen": "\f1ae",
"smile": "\f1af",
"sort": "\f1b0",
"speaker-muted-story": "\f1b1",
"speaker-outline": "\f1b2",
"speaker-story": "\f1b3",
"speaker": "\f1b4",
"spoiler-disable": "\f1b5",
"spoiler": "\f1b6",
"sport": "\f1b7",
"stats": "\f1b8",
"stealth-future": "\f1b9",
"stealth-past": "\f1ba",
"stickers": "\f1bb",
"stop-raising-hand": "\f1bc",
"stop": "\f1bd",
"story-caption": "\f1be",
"story-expired": "\f1bf",
"story-priority": "\f1c0",
"story-reply": "\f1c1",
"strikethrough": "\f1c2",
"timer": "\f1c3",
"transcribe": "\f1c4",
"truck": "\f1c5",
"unarchive": "\f1c6",
"underlined": "\f1c7",
"unlock-badge": "\f1c8",
"unlock": "\f1c9",
"unmute": "\f1ca",
"unpin": "\f1cb",
"unread": "\f1cc",
"up": "\f1cd",
"user-filled": "\f1ce",
"user-online": "\f1cf",
"user": "\f1d0",
"video-outlined": "\f1d1",
"video-stop": "\f1d2",
"video": "\f1d3",
"voice-chat": "\f1d4",
"volume-1": "\f1d5",
"volume-2": "\f1d6",
"volume-3": "\f1d7",
"web": "\f1d8",
"webapp": "\f1d9",
"word-wrap": "\f1da",
"zoom-in": "\f1db",
"zoom-out": "\f1dc",
"quote-text": "\f193",
"quote": "\f194",
"readchats": "\f195",
"recent": "\f196",
"reload": "\f197",
"remove": "\f198",
"reopen-topic": "\f199",
"replace": "\f19a",
"replies": "\f19b",
"reply-filled": "\f19c",
"reply": "\f19d",
"revote": "\f19e",
"save-story": "\f19f",
"saved-messages": "\f1a0",
"schedule": "\f1a1",
"search": "\f1a2",
"select": "\f1a3",
"send-outline": "\f1a4",
"send": "\f1a5",
"settings-filled": "\f1a6",
"settings": "\f1a7",
"share-filled": "\f1a8",
"share-screen-outlined": "\f1a9",
"share-screen-stop": "\f1aa",
"share-screen": "\f1ab",
"sidebar": "\f1ac",
"skip-next": "\f1ad",
"skip-previous": "\f1ae",
"smallscreen": "\f1af",
"smile": "\f1b0",
"sort": "\f1b1",
"speaker-muted-story": "\f1b2",
"speaker-outline": "\f1b3",
"speaker-story": "\f1b4",
"speaker": "\f1b5",
"spoiler-disable": "\f1b6",
"spoiler": "\f1b7",
"sport": "\f1b8",
"stats": "\f1b9",
"stealth-future": "\f1ba",
"stealth-past": "\f1bb",
"stickers": "\f1bc",
"stop-raising-hand": "\f1bd",
"stop": "\f1be",
"story-caption": "\f1bf",
"story-expired": "\f1c0",
"story-priority": "\f1c1",
"story-reply": "\f1c2",
"strikethrough": "\f1c3",
"timer": "\f1c4",
"transcribe": "\f1c5",
"truck": "\f1c6",
"unarchive": "\f1c7",
"underlined": "\f1c8",
"unlock-badge": "\f1c9",
"unlock": "\f1ca",
"unmute": "\f1cb",
"unpin": "\f1cc",
"unread": "\f1cd",
"up": "\f1ce",
"user-filled": "\f1cf",
"user-online": "\f1d0",
"user": "\f1d1",
"video-outlined": "\f1d2",
"video-stop": "\f1d3",
"video": "\f1d4",
"voice-chat": "\f1d5",
"volume-1": "\f1d6",
"volume-2": "\f1d7",
"volume-3": "\f1d8",
"web": "\f1d9",
"webapp": "\f1da",
"word-wrap": "\f1db",
"zoom-in": "\f1dc",
"zoom-out": "\f1dd",
);
.icon-active-sessions::before {
@ -689,6 +690,9 @@ $icons-map: (
.icon-privacy-policy::before {
content: map.get($icons-map, "privacy-policy");
}
.icon-quote-text::before {
content: map.get($icons-map, "quote-text");
}
.icon-quote::before {
content: map.get($icons-map, "quote");
}

Binary file not shown.

Binary file not shown.

View File

@ -145,6 +145,7 @@ export type FontIconName =
| 'premium'
| 'previous'
| 'privacy-policy'
| 'quote-text'
| 'quote'
| 'readchats'
| 'recent'

View File

@ -21,7 +21,7 @@ export const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> =
const MAX_TAG_DEEPNESS = 3;
export default function parseMessageInput(
export default function parseHtmlAsFormattedText(
html: string, withMarkdownLinks = false, skipMarkdown = false,
): ApiFormattedText {
const fragment = document.createElement('div');