Message: Quote highlighting (#3994)
This commit is contained in:
parent
b09f9bd255
commit
e77506cc54
@ -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)}
|
||||
</>
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user