diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx index 6115bd030..89a24a335 100644 --- a/src/components/common/MessageText.tsx +++ b/src/components/common/MessageText.tsx @@ -28,6 +28,7 @@ interface OwnProps { shouldRenderAsHtml?: boolean; inChatList?: boolean; forcePlayback?: boolean; + focusedQuote?: string; } const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; @@ -47,6 +48,7 @@ function MessageText({ shouldRenderAsHtml, inChatList, forcePlayback, + focusedQuote, }: OwnProps) { // eslint-disable-next-line no-null/no-null const sharedCanvasRef = useRef(null); @@ -101,6 +103,7 @@ function MessageText({ sharedCanvasHqRef, cacheBuster: textCacheBusterRef.current.toString(), forcePlayback, + focusedQuote, }), ].flat().filter(Boolean)} diff --git a/src/components/common/helpers/renderText.tsx b/src/components/common/helpers/renderText.tsx index 55676d58e..c68a99b48 100644 --- a/src/components/common/helpers/renderText.tsx +++ b/src/components/common/helpers/renderText.tsx @@ -22,7 +22,7 @@ import SafeLink from '../SafeLink'; export type TextFilter = ( 'escape_html' | 'hq_emoji' | 'emoji' | 'emoji_html' | 'br' | 'br_html' | 'highlight' | 'links' | - 'simple_markdown' | 'simple_markdown_html' + 'simple_markdown' | 'simple_markdown_html' | 'quote' ); const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g; @@ -30,7 +30,7 @@ const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g; export default function renderText( part: TextPart, filters: Array = ['emoji'], - params?: { highlight: string | undefined }, + params?: { highlight?: string; quote?: string }, ): TeactNode[] { if (typeof part !== 'string') { return [part]; @@ -62,6 +62,9 @@ export default function renderText( case 'highlight': return addHighlight(text, params!.highlight); + case 'quote': + return addHighlight(text, params!.quote, true); + case 'links': return addLinks(text); @@ -183,7 +186,7 @@ function addLineBreaks(textParts: TextPart[], type: 'jsx' | 'html'): TextPart[] }, []); } -function addHighlight(textParts: TextPart[], highlight: string | undefined): TextPart[] { +function addHighlight(textParts: TextPart[], highlight: string | undefined, isQuote?: true): TextPart[] { return textParts.reduce((result, part) => { if (typeof part !== 'string' || !highlight) { result.push(part); @@ -200,7 +203,7 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined): Tex const newParts: TextPart[] = []; newParts.push(part.substring(0, queryPosition)); newParts.push( - + {part.substring(queryPosition, queryPosition + highlight.length)} , ); diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 6c318d201..02c6f48ab 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -24,6 +24,7 @@ interface IOrganizedEntity { organizedIndexes: Set; nestedEntities: IOrganizedEntity[]; } +type RenderTextParams = Parameters[2]; const HQ_EMOJI_THRESHOLD = 64; @@ -43,6 +44,7 @@ export function renderTextWithEntities({ sharedCanvasHqRef, cacheBuster, forcePlayback, + focusedQuote, }: { text: string; entities?: ApiMessageEntity[]; @@ -59,9 +61,10 @@ export function renderTextWithEntities({ sharedCanvasHqRef?: React.RefObject; cacheBuster?: string; forcePlayback?: boolean; + focusedQuote?: string; }) { if (!entities?.length) { - return renderMessagePart(text, highlight, emojiSize, shouldRenderAsHtml, isSimple); + return renderMessagePart(text, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple); } const result: TextPart[] = []; @@ -90,7 +93,7 @@ export function renderTextWithEntities({ } if (textBefore) { renderResult.push(...renderMessagePart( - textBefore, highlight, emojiSize, shouldRenderAsHtml, isSimple, + textBefore, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, ) as TextPart[]); } } @@ -166,7 +169,7 @@ export function renderTextWithEntities({ } if (textAfter) { renderResult.push(...renderMessagePart( - textAfter, highlight, emojiSize, shouldRenderAsHtml, isSimple, + textAfter, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple, ) as TextPart[]); } } @@ -217,6 +220,7 @@ export function getTextWithEntitiesAsHtml(formattedText?: ApiFormattedText) { function renderMessagePart( content: TextPart | TextPart[], highlight?: string, + focusedQuote?: string, emojiSize?: number, shouldRenderAsHtml?: boolean, isSimple?: boolean, @@ -225,7 +229,7 @@ function renderMessagePart( const result: TextPart[] = []; content.forEach((c) => { - result.push(...renderMessagePart(c, highlight, emojiSize, shouldRenderAsHtml, isSimple)); + result.push(...renderMessagePart(c, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple)); }); return result; @@ -238,15 +242,21 @@ function renderMessagePart( const emojiFilter = emojiSize && emojiSize > HQ_EMOJI_THRESHOLD ? 'hq_emoji' : 'emoji'; const filters: TextFilter[] = [emojiFilter]; + const params: RenderTextParams = {}; if (!isSimple) { filters.push('br'); } if (highlight) { - return renderText(content, filters.concat('highlight'), { highlight }); - } else { - return renderText(content, filters); + filters.push('highlight'); + params.highlight = highlight; } + if (focusedQuote) { + filters.push('quote'); + params.quote = focusedQuote; + } + + return renderText(content, filters, params); } // Organize entities in a tree-like structure to better represent how the text will be displayed diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 13e091a33..9484db942 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -225,6 +225,7 @@ type StateProps = { isChatProtected?: boolean; isFocused?: boolean; focusDirection?: FocusDirection; + focusedQuote?: string; noFocusHighlight?: boolean; isResizingContainer?: boolean; isForwarding?: boolean; @@ -337,6 +338,7 @@ const Message: FC = ({ isChatProtected, isFocused, focusDirection, + focusedQuote, noFocusHighlight, isResizingContainer, isForwarding, @@ -727,7 +729,7 @@ const Message: FC = ({ ); useFocusMessage( - ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, + ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, Boolean(focusedQuote), ); const signature = (isChannel && message.postAuthorTitle) @@ -868,6 +870,7 @@ const Message: FC = ({ messageOrStory={message} translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined} isForAnimation={isForAnimation} + focusedQuote={focusedQuote} emojiSize={emojiSize} highlight={highlight} isProtected={isProtected} @@ -1488,7 +1491,7 @@ export default memo(withGlobal( ); const { - direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, + direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, quote: focusedQuote, } = (isFocused && focusedMessage) || {}; const { query: highlight } = selectCurrentTextSearch(global) || {}; @@ -1610,6 +1613,7 @@ export default memo(withGlobal( focusDirection, noFocusHighlight, isResizingContainer, + focusedQuote, }), }; }, diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index 9305b208b..aa1059a4b 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -237,7 +237,7 @@ } } - .matching-text-highlight { + .matching-text-highlight:not(.is-quote) { color: var(--color-text); background: #cae3f7; border-radius: 0.25rem; @@ -248,6 +248,23 @@ } } + .matching-text-highlight.is-quote { + background: transparent; + border-radius: 0.25rem; + + &.animate { + color: var(--color-text); + animation: quote-highlight 0.5s; + animation-delay: 1.5s; + background-color: #cae3f7; + + .theme-dark & { + animation-name: quote-highlight-dark; + color: #000; + } + } + } + .message-title { white-space: nowrap; overflow: hidden; @@ -990,3 +1007,25 @@ blockquote, .blockquote { opacity: 1; } } + +@keyframes quote-highlight { + 0% { + background-color: #cae3f7; + } + + 100% { + background-color: transparent; + } +} + +@keyframes quote-highlight-dark { + 0% { + background-color: #cae3f7; + color: #000; + } + + 100% { + background-color: transparent; + color: var(--color-text); + } +} diff --git a/src/components/middle/message/hooks/useFocusMessage.ts b/src/components/middle/message/hooks/useFocusMessage.ts index 7ffce835a..be937b1cf 100644 --- a/src/components/middle/message/hooks/useFocusMessage.ts +++ b/src/components/middle/message/hooks/useFocusMessage.ts @@ -1,8 +1,11 @@ import { useLayoutEffect, useRef } from '../../../../lib/teact/teact'; +import { addExtraClass } from '../../../../lib/teact/teact-dom'; import type { FocusDirection } from '../../../../types'; -import { requestForcedReflow, requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom'; +import { + requestForcedReflow, requestMeasure, requestMutation, +} from '../../../../lib/fasterdom/fasterdom'; import animateScroll from '../../../../util/animateScroll'; // This is used when the viewport was replaced. @@ -18,6 +21,7 @@ export default function useFocusMessage( noFocusHighlight?: boolean, isResizingContainer?: boolean, isJustAdded?: boolean, + isQuote?: boolean, ) { const isRelocatedRef = useRef(!isJustAdded); @@ -30,17 +34,30 @@ export default function useFocusMessage( // `noFocusHighlight` is always called with “scroll-to-bottom” buttons const isToBottom = noFocusHighlight; - const exec = () => animateScroll( - messagesContainer, - elementRef.current!, - isToBottom ? 'end' : 'centerOrTop', - FOCUS_MARGIN, - focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, - focusDirection, - undefined, - isResizingContainer, - true, - ); + const exec = () => { + const result = animateScroll( + messagesContainer, + elementRef.current!, + isToBottom ? 'end' : 'centerOrTop', + FOCUS_MARGIN, + focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, + focusDirection, + undefined, + isResizingContainer, + true, + ); + + if (isQuote) { + const firstQuote = elementRef.current!.querySelector('.is-quote'); + if (firstQuote) { + requestMutation(() => { + addExtraClass(firstQuote, 'animate'); + }); + } + } + + return result; + }; if (isRelocated) { // We need this to override scroll setting from Message List layout effect @@ -52,6 +69,6 @@ export default function useFocusMessage( } } }, [ - elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, + elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isQuote, ]); } diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index e2db9d54d..b7eb78dba 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -43,7 +43,7 @@ export default function useInnerHandlers( } = message; const { - replyToMsgId, replyToPeerId, replyToTopId, isQuote, + replyToMsgId, replyToPeerId, replyToTopId, isQuote, quoteText, } = getMessageReplyInfo(message) || {}; const handleAvatarClick = useLastCallback(() => { @@ -90,6 +90,7 @@ export default function useInnerHandlers( messageId: replyToMsgId, replyMessageId: replyToPeerId ? undefined : messageId, noForumTopicPanel: !replyToPeerId, // Open topic panel for cross-chat replies + ...(isQuote && { quote: quoteText?.text }), }); }); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 74d4adaa2..2559e2a07 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -386,7 +386,7 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => { const { chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, - replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, + replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote, tabId = getCurrentTabId(), } = payload; @@ -412,12 +412,20 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => } blurTimeout = window.setTimeout(() => { global = getGlobal(); - global = updateFocusedMessage(global, undefined, undefined, undefined, undefined, undefined, tabId); + global = updateFocusedMessage({ global }, tabId); global = updateFocusDirection(global, undefined, tabId); setGlobal(global); }, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION); - global = updateFocusedMessage(global, chatId, messageId, threadId, noHighlight, isResizingContainer, tabId); + global = updateFocusedMessage({ + global, + chatId, + messageId, + threadId, + noHighlight, + isResizingContainer, + quote, + }, tabId); global = updateFocusDirection(global, undefined, tabId); if (replyMessageId) { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index b673da336..936ff123f 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -527,11 +527,24 @@ function updateScheduledMessages( }; } -export function updateFocusedMessage( - global: T, chatId?: string, messageId?: number, threadId = MAIN_THREAD_ID, noHighlight = false, +export function updateFocusedMessage({ + global, + chatId, + messageId, + threadId = MAIN_THREAD_ID, + noHighlight = false, isResizingContainer = false, - ...[tabId = getCurrentTabId()]: TabArgs -): T { + quote, +}: { + global: T; + chatId?: string; + messageId?: number; + threadId?: number; + noHighlight?: boolean; + isResizingContainer?: boolean; + quote?: string; +}, +...[tabId = getCurrentTabId()]: TabArgs): T { return updateTabState(global, { focusedMessage: { ...selectTabState(global, tabId).focusedMessage, @@ -540,6 +553,7 @@ export function updateFocusedMessage( messageId, noHighlight, isResizingContainer, + quote, }, }, tabId); } diff --git a/src/global/types.ts b/src/global/types.ts index a4055728f..ac93a72c0 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -254,6 +254,7 @@ export type TabState = { direction?: FocusDirection; noHighlight?: boolean; isResizingContainer?: boolean; + quote?: string; }; selectedMessages?: { @@ -1654,6 +1655,7 @@ export interface ActionPayloads { isResizingContainer?: boolean; shouldReplaceHistory?: boolean; noForumTopicPanel?: boolean; + quote?: string; } & WithTabId; focusLastMessage: WithTabId | undefined;