Message: Quote highlighting (#3994)

This commit is contained in:
Alexander Zinchuk 2023-12-04 14:38:13 +01:00
parent b09f9bd255
commit e77506cc54
10 changed files with 136 additions and 35 deletions

View File

@ -28,6 +28,7 @@ interface OwnProps {
shouldRenderAsHtml?: boolean; shouldRenderAsHtml?: boolean;
inChatList?: boolean; inChatList?: boolean;
forcePlayback?: boolean; forcePlayback?: boolean;
focusedQuote?: string;
} }
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
@ -47,6 +48,7 @@ function MessageText({
shouldRenderAsHtml, shouldRenderAsHtml,
inChatList, inChatList,
forcePlayback, forcePlayback,
focusedQuote,
}: OwnProps) { }: OwnProps) {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null); const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
@ -101,6 +103,7 @@ function MessageText({
sharedCanvasHqRef, sharedCanvasHqRef,
cacheBuster: textCacheBusterRef.current.toString(), cacheBuster: textCacheBusterRef.current.toString(),
forcePlayback, forcePlayback,
focusedQuote,
}), }),
].flat().filter(Boolean)} ].flat().filter(Boolean)}
</> </>

View File

@ -22,7 +22,7 @@ import SafeLink from '../SafeLink';
export type TextFilter = ( export type TextFilter = (
'escape_html' | 'hq_emoji' | 'emoji' | 'emoji_html' | 'br' | 'br_html' | 'highlight' | 'links' | '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; const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
@ -30,7 +30,7 @@ const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
export default function renderText( export default function renderText(
part: TextPart, part: TextPart,
filters: Array<TextFilter> = ['emoji'], filters: Array<TextFilter> = ['emoji'],
params?: { highlight: string | undefined }, params?: { highlight?: string; quote?: string },
): TeactNode[] { ): TeactNode[] {
if (typeof part !== 'string') { if (typeof part !== 'string') {
return [part]; return [part];
@ -62,6 +62,9 @@ export default function renderText(
case 'highlight': case 'highlight':
return addHighlight(text, params!.highlight); return addHighlight(text, params!.highlight);
case 'quote':
return addHighlight(text, params!.quote, true);
case 'links': case 'links':
return addLinks(text); 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<TextPart[]>((result, part) => { return textParts.reduce<TextPart[]>((result, part) => {
if (typeof part !== 'string' || !highlight) { if (typeof part !== 'string' || !highlight) {
result.push(part); result.push(part);
@ -200,7 +203,7 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined): Tex
const newParts: TextPart[] = []; const newParts: TextPart[] = [];
newParts.push(part.substring(0, queryPosition)); newParts.push(part.substring(0, queryPosition));
newParts.push( newParts.push(
<span className="matching-text-highlight"> <span className={buildClassName('matching-text-highlight', isQuote && 'is-quote')}>
{part.substring(queryPosition, queryPosition + highlight.length)} {part.substring(queryPosition, queryPosition + highlight.length)}
</span>, </span>,
); );

View File

@ -24,6 +24,7 @@ interface IOrganizedEntity {
organizedIndexes: Set<number>; organizedIndexes: Set<number>;
nestedEntities: IOrganizedEntity[]; nestedEntities: IOrganizedEntity[];
} }
type RenderTextParams = Parameters<typeof renderText>[2];
const HQ_EMOJI_THRESHOLD = 64; const HQ_EMOJI_THRESHOLD = 64;
@ -43,6 +44,7 @@ export function renderTextWithEntities({
sharedCanvasHqRef, sharedCanvasHqRef,
cacheBuster, cacheBuster,
forcePlayback, forcePlayback,
focusedQuote,
}: { }: {
text: string; text: string;
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
@ -59,9 +61,10 @@ export function renderTextWithEntities({
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>; sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
cacheBuster?: string; cacheBuster?: string;
forcePlayback?: boolean; forcePlayback?: boolean;
focusedQuote?: string;
}) { }) {
if (!entities?.length) { if (!entities?.length) {
return renderMessagePart(text, highlight, emojiSize, shouldRenderAsHtml, isSimple); return renderMessagePart(text, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple);
} }
const result: TextPart[] = []; const result: TextPart[] = [];
@ -90,7 +93,7 @@ export function renderTextWithEntities({
} }
if (textBefore) { if (textBefore) {
renderResult.push(...renderMessagePart( renderResult.push(...renderMessagePart(
textBefore, highlight, emojiSize, shouldRenderAsHtml, isSimple, textBefore, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple,
) as TextPart[]); ) as TextPart[]);
} }
} }
@ -166,7 +169,7 @@ export function renderTextWithEntities({
} }
if (textAfter) { if (textAfter) {
renderResult.push(...renderMessagePart( renderResult.push(...renderMessagePart(
textAfter, highlight, emojiSize, shouldRenderAsHtml, isSimple, textAfter, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple,
) as TextPart[]); ) as TextPart[]);
} }
} }
@ -217,6 +220,7 @@ export function getTextWithEntitiesAsHtml(formattedText?: ApiFormattedText) {
function renderMessagePart( function renderMessagePart(
content: TextPart | TextPart[], content: TextPart | TextPart[],
highlight?: string, highlight?: string,
focusedQuote?: string,
emojiSize?: number, emojiSize?: number,
shouldRenderAsHtml?: boolean, shouldRenderAsHtml?: boolean,
isSimple?: boolean, isSimple?: boolean,
@ -225,7 +229,7 @@ function renderMessagePart(
const result: TextPart[] = []; const result: TextPart[] = [];
content.forEach((c) => { content.forEach((c) => {
result.push(...renderMessagePart(c, highlight, emojiSize, shouldRenderAsHtml, isSimple)); result.push(...renderMessagePart(c, highlight, focusedQuote, emojiSize, shouldRenderAsHtml, isSimple));
}); });
return result; return result;
@ -238,15 +242,21 @@ function renderMessagePart(
const emojiFilter = emojiSize && emojiSize > HQ_EMOJI_THRESHOLD ? 'hq_emoji' : 'emoji'; const emojiFilter = emojiSize && emojiSize > HQ_EMOJI_THRESHOLD ? 'hq_emoji' : 'emoji';
const filters: TextFilter[] = [emojiFilter]; const filters: TextFilter[] = [emojiFilter];
const params: RenderTextParams = {};
if (!isSimple) { if (!isSimple) {
filters.push('br'); filters.push('br');
} }
if (highlight) { if (highlight) {
return renderText(content, filters.concat('highlight'), { highlight }); filters.push('highlight');
} else { params.highlight = highlight;
return renderText(content, filters);
} }
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 // Organize entities in a tree-like structure to better represent how the text will be displayed

View File

@ -225,6 +225,7 @@ type StateProps = {
isChatProtected?: boolean; isChatProtected?: boolean;
isFocused?: boolean; isFocused?: boolean;
focusDirection?: FocusDirection; focusDirection?: FocusDirection;
focusedQuote?: string;
noFocusHighlight?: boolean; noFocusHighlight?: boolean;
isResizingContainer?: boolean; isResizingContainer?: boolean;
isForwarding?: boolean; isForwarding?: boolean;
@ -337,6 +338,7 @@ const Message: FC<OwnProps & StateProps> = ({
isChatProtected, isChatProtected,
isFocused, isFocused,
focusDirection, focusDirection,
focusedQuote,
noFocusHighlight, noFocusHighlight,
isResizingContainer, isResizingContainer,
isForwarding, isForwarding,
@ -727,7 +729,7 @@ const Message: FC<OwnProps & StateProps> = ({
); );
useFocusMessage( useFocusMessage(
ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, Boolean(focusedQuote),
); );
const signature = (isChannel && message.postAuthorTitle) const signature = (isChannel && message.postAuthorTitle)
@ -868,6 +870,7 @@ const Message: FC<OwnProps & StateProps> = ({
messageOrStory={message} messageOrStory={message}
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined} translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
isForAnimation={isForAnimation} isForAnimation={isForAnimation}
focusedQuote={focusedQuote}
emojiSize={emojiSize} emojiSize={emojiSize}
highlight={highlight} highlight={highlight}
isProtected={isProtected} isProtected={isProtected}
@ -1488,7 +1491,7 @@ export default memo(withGlobal<OwnProps>(
); );
const { const {
direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, quote: focusedQuote,
} = (isFocused && focusedMessage) || {}; } = (isFocused && focusedMessage) || {};
const { query: highlight } = selectCurrentTextSearch(global) || {}; const { query: highlight } = selectCurrentTextSearch(global) || {};
@ -1610,6 +1613,7 @@ export default memo(withGlobal<OwnProps>(
focusDirection, focusDirection,
noFocusHighlight, noFocusHighlight,
isResizingContainer, isResizingContainer,
focusedQuote,
}), }),
}; };
}, },

View File

@ -237,7 +237,7 @@
} }
} }
.matching-text-highlight { .matching-text-highlight:not(.is-quote) {
color: var(--color-text); color: var(--color-text);
background: #cae3f7; background: #cae3f7;
border-radius: 0.25rem; 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 { .message-title {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -990,3 +1007,25 @@ blockquote, .blockquote {
opacity: 1; 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);
}
}

View File

@ -1,8 +1,11 @@
import { useLayoutEffect, useRef } from '../../../../lib/teact/teact'; import { useLayoutEffect, useRef } from '../../../../lib/teact/teact';
import { addExtraClass } from '../../../../lib/teact/teact-dom';
import type { FocusDirection } from '../../../../types'; 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'; import animateScroll from '../../../../util/animateScroll';
// This is used when the viewport was replaced. // This is used when the viewport was replaced.
@ -18,6 +21,7 @@ export default function useFocusMessage(
noFocusHighlight?: boolean, noFocusHighlight?: boolean,
isResizingContainer?: boolean, isResizingContainer?: boolean,
isJustAdded?: boolean, isJustAdded?: boolean,
isQuote?: boolean,
) { ) {
const isRelocatedRef = useRef(!isJustAdded); const isRelocatedRef = useRef(!isJustAdded);
@ -30,17 +34,30 @@ export default function useFocusMessage(
// `noFocusHighlight` is always called with “scroll-to-bottom” buttons // `noFocusHighlight` is always called with “scroll-to-bottom” buttons
const isToBottom = noFocusHighlight; const isToBottom = noFocusHighlight;
const exec = () => animateScroll( const exec = () => {
messagesContainer, const result = animateScroll(
elementRef.current!, messagesContainer,
isToBottom ? 'end' : 'centerOrTop', elementRef.current!,
FOCUS_MARGIN, isToBottom ? 'end' : 'centerOrTop',
focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, FOCUS_MARGIN,
focusDirection, focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined,
undefined, focusDirection,
isResizingContainer, undefined,
true, isResizingContainer,
); true,
);
if (isQuote) {
const firstQuote = elementRef.current!.querySelector<HTMLSpanElement>('.is-quote');
if (firstQuote) {
requestMutation(() => {
addExtraClass(firstQuote, 'animate');
});
}
}
return result;
};
if (isRelocated) { if (isRelocated) {
// We need this to override scroll setting from Message List layout effect // 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,
]); ]);
} }

View File

@ -43,7 +43,7 @@ export default function useInnerHandlers(
} = message; } = message;
const { const {
replyToMsgId, replyToPeerId, replyToTopId, isQuote, replyToMsgId, replyToPeerId, replyToTopId, isQuote, quoteText,
} = getMessageReplyInfo(message) || {}; } = getMessageReplyInfo(message) || {};
const handleAvatarClick = useLastCallback(() => { const handleAvatarClick = useLastCallback(() => {
@ -90,6 +90,7 @@ export default function useInnerHandlers(
messageId: replyToMsgId, messageId: replyToMsgId,
replyMessageId: replyToPeerId ? undefined : messageId, replyMessageId: replyToPeerId ? undefined : messageId,
noForumTopicPanel: !replyToPeerId, // Open topic panel for cross-chat replies noForumTopicPanel: !replyToPeerId, // Open topic panel for cross-chat replies
...(isQuote && { quote: quoteText?.text }),
}); });
}); });

View File

@ -386,7 +386,7 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType
addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => { addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => {
const { const {
chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId,
replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -412,12 +412,20 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
} }
blurTimeout = window.setTimeout(() => { blurTimeout = window.setTimeout(() => {
global = getGlobal(); global = getGlobal();
global = updateFocusedMessage(global, undefined, undefined, undefined, undefined, undefined, tabId); global = updateFocusedMessage({ global }, tabId);
global = updateFocusDirection(global, undefined, tabId); global = updateFocusDirection(global, undefined, tabId);
setGlobal(global); setGlobal(global);
}, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION); }, 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); global = updateFocusDirection(global, undefined, tabId);
if (replyMessageId) { if (replyMessageId) {

View File

@ -527,11 +527,24 @@ function updateScheduledMessages<T extends GlobalState>(
}; };
} }
export function updateFocusedMessage<T extends GlobalState>( export function updateFocusedMessage<T extends GlobalState>({
global: T, chatId?: string, messageId?: number, threadId = MAIN_THREAD_ID, noHighlight = false, global,
chatId,
messageId,
threadId = MAIN_THREAD_ID,
noHighlight = false,
isResizingContainer = false, isResizingContainer = false,
...[tabId = getCurrentTabId()]: TabArgs<T> quote,
): T { }: {
global: T;
chatId?: string;
messageId?: number;
threadId?: number;
noHighlight?: boolean;
isResizingContainer?: boolean;
quote?: string;
},
...[tabId = getCurrentTabId()]: TabArgs<T>): T {
return updateTabState(global, { return updateTabState(global, {
focusedMessage: { focusedMessage: {
...selectTabState(global, tabId).focusedMessage, ...selectTabState(global, tabId).focusedMessage,
@ -540,6 +553,7 @@ export function updateFocusedMessage<T extends GlobalState>(
messageId, messageId,
noHighlight, noHighlight,
isResizingContainer, isResizingContainer,
quote,
}, },
}, tabId); }, tabId);
} }

View File

@ -254,6 +254,7 @@ export type TabState = {
direction?: FocusDirection; direction?: FocusDirection;
noHighlight?: boolean; noHighlight?: boolean;
isResizingContainer?: boolean; isResizingContainer?: boolean;
quote?: string;
}; };
selectedMessages?: { selectedMessages?: {
@ -1654,6 +1655,7 @@ export interface ActionPayloads {
isResizingContainer?: boolean; isResizingContainer?: boolean;
shouldReplaceHistory?: boolean; shouldReplaceHistory?: boolean;
noForumTopicPanel?: boolean; noForumTopicPanel?: boolean;
quote?: string;
} & WithTabId; } & WithTabId;
focusLastMessage: WithTabId | undefined; focusLastMessage: WithTabId | undefined;