From 89df119add7490fdcd9b15c5e4b0365eff1811dd Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 12 Dec 2023 12:34:23 +0100 Subject: [PATCH] Message: Allow quote message (#4067) --- src/api/gramjs/apiBuilders/messages.ts | 1 + src/assets/font-icons/quote-text.svg | 1 + src/components/common/Composer.tsx | 8 +- src/components/common/CustomEmoji.tsx | 9 +- .../common/embedded/EmbeddedMessage.tsx | 9 +- .../common/helpers/renderTextWithEntities.tsx | 98 ++++++++--- .../composer/ComposerEmbeddedMessage.scss | 12 ++ .../composer/ComposerEmbeddedMessage.tsx | 19 ++- src/components/middle/composer/PollModal.tsx | 5 +- .../middle/composer/WebPagePreview.tsx | 4 +- .../middle/composer/helpers/cleanHtml.ts | 2 +- .../composer/helpers/getHtmlTextLength.ts | 2 +- .../composer/hooks/useClipboardPaste.ts | 4 +- .../middle/composer/hooks/useDraft.ts | 4 +- .../middle/composer/hooks/useEditing.ts | 10 +- .../middle/message/ContextMenuContainer.tsx | 44 ++++- src/components/middle/message/Message.tsx | 6 +- .../middle/message/MessageContextMenu.tsx | 8 +- .../middle/message/ReactionPicker.tsx | 4 +- .../middle/message/_message-content.scss | 6 + .../helpers/getSelectionAsFormattedText.ts | 57 +++++++ .../helpers/isSelectionRangeInsideMessage.ts | 9 ++ src/global/actions/ui/messages.ts | 4 +- src/styles/icons.scss | 152 +++++++++--------- src/styles/icons.woff | Bin 27600 -> 27740 bytes src/styles/icons.woff2 | Bin 23148 -> 23244 bytes src/types/icons/font.ts | 1 + ...geInput.ts => parseHtmlAsFormattedText.ts} | 2 +- 28 files changed, 345 insertions(+), 136 deletions(-) create mode 100644 src/assets/font-icons/quote-text.svg create mode 100644 src/components/middle/message/helpers/getSelectionAsFormattedText.ts create mode 100644 src/components/middle/message/helpers/isSelectionRangeInsideMessage.ts rename src/util/{parseMessageInput.ts => parseHtmlAsFormattedText.ts} (99%) diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 662fbab43..7329c6487 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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 }), }; } diff --git a/src/assets/font-icons/quote-text.svg b/src/assets/font-icons/quote-text.svg new file mode 100644 index 000000000..2ed4807e0 --- /dev/null +++ b/src/assets/font-icons/quote-text.svg @@ -0,0 +1 @@ + diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index cf725e30e..a08bed69f 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -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 = ({ return; } - const { text, entities } = parseMessageInput(getHtml()); + const { text, entities } = parseHtmlAsFormattedText(getHtml()); if (!text && !attachmentsToSend.length) { return; } @@ -931,7 +931,7 @@ const Composer: FC = ({ } } - const { text, entities } = parseMessageInput(getHtml()); + const { text, entities } = parseHtmlAsFormattedText(getHtml()); if (currentAttachments.length) { sendAttachments({ @@ -1398,7 +1398,7 @@ const Composer: FC = ({ showCustomEmojiPremiumNotification(); return; } - const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker)); + const customEmojiMessage = parseHtmlAsFormattedText(buildCustomEmojiHtml(sticker)); text = customEmojiMessage.text; entities = customEmojiMessage.entities; } diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index 24eccff6b..3bc31910f 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -127,7 +127,14 @@ const CustomEmoji: FC = ({ data-alt={customEmoji?.emoji} style={style} > - {customEmoji?.emoji} + {customEmoji?.emoji} {!customEmoji ? ( Emoji ) : ( diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index 563d96ca9..a17a55d36 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -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 = ({ title, customText, isProtected, + isInComposer, noUserColors, chatTranslations, requestedChatTranslationLanguage, @@ -118,6 +120,7 @@ const EmbeddedMessage: FC = ({ return renderTextWithEntities({ text: replyInfo.quoteText.text, entities: replyInfo.quoteText.entities, + noLineBreaks: isInComposer, }); } @@ -173,7 +176,11 @@ const EmbeddedMessage: FC = ({ return ( <> - {!isChatSender && {renderText(senderTitle)}} + {!isChatSender && ( + + {renderText(isInComposer ? lang('ReplyToQuote', senderTitle) : senderTitle)} + + )} {icon && } {icon && senderChatTitle && renderText(senderChatTitle)} diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index df3fcd3e4..3453d6f9d 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -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 {renderNestedMessagePart()}; case ApiMessageEntityTypes.Blockquote: return ( -
+
{renderNestedMessagePart()}
-
+ ); case ApiMessageEntityTypes.BotCommand: return ( @@ -585,10 +635,10 @@ function processEntityAsHtml( case ApiMessageEntityTypes.CustomEmoji: return buildCustomEmojiHtmlFromEntity(rawEntityText, entity); case ApiMessageEntityTypes.Blockquote: - return `${renderedContent}`; + >${renderedContent}`; default: return renderedContent; } diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.scss b/src/components/middle/composer/ComposerEmbeddedMessage.scss index ecdd79ddd..a8ede1041 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.scss +++ b/src/components/middle/composer/ComposerEmbeddedMessage.scss @@ -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; + } + } } diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index 1eaef8ae2..542a70606 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -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 = ({ 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 = ({
- + {leftIcon && } + {Boolean(replyInfo?.quoteText) && ( + + )}
( 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( 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) => ( diff --git a/src/components/middle/composer/PollModal.tsx b/src/components/middle/composer/PollModal.tsx index 68c005552..24bc4e3b6 100644 --- a/src/components/middle/composer/PollModal.tsx +++ b/src/components/middle/composer/PollModal.tsx @@ -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 = ({ }; 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)], diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index ab2116d6f..cb0733e2e 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -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 = ({ const formattedTextWithLinkRef = useRef(); const detectLinkDebounced = useDebouncedResolver(() => { - const formattedText = parseMessageInput(getHtml()); + const formattedText = parseHtmlAsFormattedText(getHtml()); const linkEntity = formattedText.entities?.find((entity): entity is ApiMessageEntityTextUrl => ( entity.type === ApiMessageEntityTypes.TextUrl )); diff --git a/src/components/middle/composer/helpers/cleanHtml.ts b/src/components/middle/composer/helpers/cleanHtml.ts index 3d4c76002..45962da36 100644 --- a/src/components/middle/composer/helpers/cleanHtml.ts +++ b/src/components/middle/composer/helpers/cleanHtml.ts @@ -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 = /