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;
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<HTMLCanvasElement>(null);
@ -101,6 +103,7 @@ function MessageText({
sharedCanvasHqRef,
cacheBuster: textCacheBusterRef.current.toString(),
forcePlayback,
focusedQuote,
}),
].flat().filter(Boolean)}
</>

View File

@ -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<TextFilter> = ['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<TextPart[]>((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(
<span className="matching-text-highlight">
<span className={buildClassName('matching-text-highlight', isQuote && 'is-quote')}>
{part.substring(queryPosition, queryPosition + highlight.length)}
</span>,
);

View File

@ -24,6 +24,7 @@ interface IOrganizedEntity {
organizedIndexes: Set<number>;
nestedEntities: IOrganizedEntity[];
}
type RenderTextParams = Parameters<typeof renderText>[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<HTMLCanvasElement>;
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

View File

@ -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<OwnProps & StateProps> = ({
isChatProtected,
isFocused,
focusDirection,
focusedQuote,
noFocusHighlight,
isResizingContainer,
isForwarding,
@ -727,7 +729,7 @@ const Message: FC<OwnProps & StateProps> = ({
);
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<OwnProps & StateProps> = ({
messageOrStory={message}
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
isForAnimation={isForAnimation}
focusedQuote={focusedQuote}
emojiSize={emojiSize}
highlight={highlight}
isProtected={isProtected}
@ -1488,7 +1491,7 @@ export default memo(withGlobal<OwnProps>(
);
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<OwnProps>(
focusDirection,
noFocusHighlight,
isResizingContainer,
focusedQuote,
}),
};
},

View File

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

View File

@ -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<HTMLSpanElement>('.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,
]);
}

View File

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

View File

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

View File

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

View File

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