Message: Support quote highlight with entities (#5842)

This commit is contained in:
zubiden 2025-04-23 18:59:11 +02:00 committed by Alexander Zinchuk
parent 185a42be5d
commit 58e79daddb
15 changed files with 149 additions and 45 deletions

View File

@ -286,6 +286,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
replyToTopId: replyTo.topMsgId,
replyToPeerId: replyTo.replyToPeerId && getApiChatIdFromMtpPeer(replyTo.replyToPeerId),
quoteText: replyTo.quoteText ? buildMessageTextContent(replyTo.quoteText, replyTo.quoteEntities) : undefined,
quoteOffset: replyTo.quoteOffset,
} satisfies ApiInputMessageReplyInfo : undefined;
return {
@ -340,6 +341,7 @@ function buildApiReplyInfo(
quote,
quoteText,
quoteEntities,
quoteOffset,
} = replyHeader;
return {
@ -352,6 +354,7 @@ function buildApiReplyInfo(
replyMedia: replyMedia && buildMessageMediaContent(replyMedia, context),
isQuote: quote,
quoteText: quoteText ? buildMessageTextContent(quoteText, quoteEntities) : undefined,
quoteOffset,
};
}
@ -554,6 +557,7 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep
replyToTopId: inputInfo.replyToTopId,
replyToPeerId: inputInfo.replyToPeerId,
quoteText: inputInfo.quoteText,
quoteOffset: inputInfo.quoteOffset,
isForumTopic: isForum && inputInfo.replyToTopId ? true : undefined,
...(Boolean(inputInfo.quoteText) && { isQuote: true }),
};

View File

@ -805,7 +805,7 @@ export function buildInputReplyTo(replyInfo: ApiInputReplyInfo) {
if (replyInfo.type === 'message') {
const {
replyToMsgId, replyToTopId, replyToPeerId, quoteText,
replyToMsgId, replyToTopId, replyToPeerId, quoteText, quoteOffset,
} = replyInfo;
return new GramJs.InputReplyToMessage({
replyToMsgId,
@ -813,6 +813,7 @@ export function buildInputReplyTo(replyInfo: ApiInputReplyInfo) {
replyToPeerId: replyToPeerId ? buildInputPeerFromLocalDb(replyToPeerId)! : undefined,
quoteText: quoteText?.text,
quoteEntities: quoteText?.entities?.map(buildMtpMessageEntity),
quoteOffset,
});
}

View File

@ -365,6 +365,7 @@ export interface ApiMessageReplyInfo {
isForumTopic?: true;
isQuote?: true;
quoteText?: ApiFormattedText;
quoteOffset?: number;
}
export interface ApiStoryReplyInfo {
@ -379,6 +380,7 @@ export interface ApiInputMessageReplyInfo {
replyToTopId?: number;
replyToPeerId?: string;
quoteText?: ApiFormattedText;
quoteOffset?: number;
}
export interface ApiInputStoryReplyInfo {
@ -457,6 +459,7 @@ export type ApiMessageEntityCustomEmoji = {
documentId: string;
};
// Local entities
export type ApiMessageEntityTimestamp = {
type: ApiMessageEntityTypes.Timestamp;
offset: number;
@ -464,8 +467,15 @@ export type ApiMessageEntityTimestamp = {
timestamp: number;
};
export type ApiMessageEntityQuoteFocus = {
type: 'quoteFocus';
offset: number;
length: number;
};
export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl |
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp;
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp |
ApiMessageEntityQuoteFocus;
export enum ApiMessageEntityTypes {
Bold = 'MessageEntityBold',
@ -487,6 +497,7 @@ export enum ApiMessageEntityTypes {
Spoiler = 'MessageEntitySpoiler',
CustomEmoji = 'MessageEntityCustomEmoji',
Timestamp = 'MessageEntityTimestamp',
QuoteFocus = 'MessageEntityQuoteFocus',
Unknown = 'MessageEntityUnknown',
}

View File

@ -10,7 +10,7 @@ import { ApiMessageEntityTypes } from '../../api/types';
import { CONTENT_NOT_SUPPORTED } from '../../config';
import { extractMessageText, stripCustomEmoji } from '../../global/helpers';
import trimText from '../../util/trimText';
import { renderTextWithEntities } from './helpers/renderTextWithEntities';
import { insertTextEntity, renderTextWithEntities } from './helpers/renderTextWithEntities';
import useSyncEffect from '../../hooks/useSyncEffect';
import useUniqueId from '../../hooks/useUniqueId';
@ -32,6 +32,7 @@ interface OwnProps {
inChatList?: boolean;
forcePlayback?: boolean;
focusedQuote?: string;
focusedQuoteOffset?: number;
isInSelectMode?: boolean;
canBeEmpty?: boolean;
maxTimestamp?: number;
@ -55,6 +56,7 @@ function MessageText({
inChatList,
forcePlayback,
focusedQuote,
focusedQuoteOffset,
isInSelectMode,
canBeEmpty,
maxTimestamp,
@ -71,21 +73,38 @@ function MessageText({
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
const { text, entities } = adaptedFormattedText || {};
const entitiesWithFocusedQuote = useMemo(() => {
if (!text || !focusedQuote) return entities;
const index = text.indexOf(focusedQuote, focusedQuoteOffset);
const lendth = focusedQuote.length;
if (index >= 0) {
return insertTextEntity(entities || [], {
offset: index,
length: lendth,
type: ApiMessageEntityTypes.QuoteFocus,
});
}
return entities;
}, [text, entities, focusedQuote, focusedQuoteOffset]);
const containerId = useUniqueId();
useSyncEffect(() => {
textCacheBusterRef.current += 1;
}, [text, entities]);
}, [text, entitiesWithFocusedQuote]);
const withSharedCanvas = useMemo(() => {
const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler);
const hasSpoilers = entitiesWithFocusedQuote?.some((e) => e.type === ApiMessageEntityTypes.Spoiler);
if (hasSpoilers) {
return false;
}
const customEmojisCount = entities?.filter((e) => e.type === ApiMessageEntityTypes.CustomEmoji).length || 0;
const customEmojisCount = entitiesWithFocusedQuote
?.filter((e) => e.type === ApiMessageEntityTypes.CustomEmoji).length || 0;
return customEmojisCount >= MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS;
}, [entities]) || 0;
}, [entitiesWithFocusedQuote]) || 0;
if (!text && !canBeEmpty) {
return <span className="content-unsupported">{CONTENT_NOT_SUPPORTED}</span>;
@ -98,7 +117,7 @@ function MessageText({
withSharedCanvas && <canvas ref={sharedCanvasHqRef} className="shared-canvas" />,
renderTextWithEntities({
text: trimText(text!, truncateLength),
entities,
entities: entitiesWithFocusedQuote,
highlight,
emojiSize,
shouldRenderAsHtml,
@ -112,7 +131,6 @@ function MessageText({
sharedCanvasHqRef,
cacheBuster: textCacheBusterRef.current.toString(),
forcePlayback,
focusedQuote,
isInSelectMode,
maxTimestamp,
chatId: 'chatId' in messageOrStory ? messageOrStory.chatId : undefined,

View File

@ -23,7 +23,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' | 'quote' | 'tg_links'
'simple_markdown' | 'simple_markdown_html' | 'tg_links'
);
const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
@ -31,7 +31,10 @@ const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
export default function renderText(
part: TextPart,
filters: Array<TextFilter> = ['emoji'],
params?: { highlight?: string; quote?: string; markdownPostProcessor?: (part: string) => TeactNode },
params?: {
highlight?: string;
markdownPostProcessor?: (part: string) => TeactNode;
},
): TeactNode[] {
if (typeof part !== 'string') {
return [part];
@ -63,9 +66,6 @@ export default function renderText(
case 'highlight':
return addHighlight(text, params!.highlight);
case 'quote':
return addHighlight(text, params!.quote, true);
case 'links':
return addLinks(text);
@ -190,7 +190,7 @@ function addLineBreaks(textParts: TextPart[], type: 'jsx' | 'html'): TextPart[]
}, []);
}
function addHighlight(textParts: TextPart[], highlight: string | undefined, isQuote?: true): TextPart[] {
function addHighlight(textParts: TextPart[], highlight: string | undefined): TextPart[] {
return textParts.reduce<TextPart[]>((result, part) => {
if (typeof part !== 'string' || !highlight) {
result.push(part);
@ -207,7 +207,7 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined, isQu
const newParts: TextPart[] = [];
newParts.push(part.substring(0, queryPosition));
newParts.push(
<span className={buildClassName('matching-text-highlight', isQuote && 'is-quote')}>
<span className="matching-text-highlight">
{part.substring(queryPosition, queryPosition + highlight.length)}
</span>,
);

View File

@ -46,7 +46,6 @@ export function renderTextWithEntities({
cacheBuster,
forcePlayback,
noCustomEmojiPlayback,
focusedQuote,
isInSelectMode,
chatId,
messageId,
@ -69,7 +68,6 @@ export function renderTextWithEntities({
cacheBuster?: string;
forcePlayback?: boolean;
noCustomEmojiPlayback?: boolean;
focusedQuote?: string;
isInSelectMode?: boolean;
chatId?: string;
messageId?: number;
@ -80,7 +78,6 @@ export function renderTextWithEntities({
return renderMessagePart({
content: text,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
asPreview,
@ -115,7 +112,6 @@ export function renderTextWithEntities({
renderResult.push(...renderMessagePart({
content: textBefore,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
asPreview,
@ -166,7 +162,6 @@ export function renderTextWithEntities({
entityContent,
nestedEntityContent,
highlight,
focusedQuote,
containerId,
asPreview,
isProtected,
@ -203,7 +198,6 @@ export function renderTextWithEntities({
renderResult.push(...renderMessagePart({
content: textAfter,
highlight,
focusedQuote,
emojiSize,
shouldRenderAsHtml,
asPreview,
@ -302,14 +296,63 @@ function renderMessagePart({
filters.push('highlight');
params.highlight = highlight;
}
if (focusedQuote) {
filters.push('quote');
params.quote = focusedQuote;
}
return renderText(content, filters, params);
}
export function insertTextEntity(entities: ApiMessageEntity[], newEntity: ApiMessageEntity) {
const resultEntities: ApiMessageEntity[] = [];
const newEntityStart = newEntity.offset;
const newEntityEnd = newEntity.offset + newEntity.length;
for (const existingEntity of entities) {
const existingEntityStart = existingEntity.offset;
const existingEntityEnd = existingEntity.offset + existingEntity.length;
// Push as is if edges do not overlap
if (existingEntityEnd <= newEntityStart
|| existingEntityStart > newEntityEnd
|| (existingEntityStart > newEntityStart
&& existingEntityEnd < newEntityEnd)) {
resultEntities.push(existingEntity);
continue;
}
// If start edge overlaps
if (existingEntityStart < newEntityStart && existingEntityEnd > newEntityStart) {
// Split entity in two
resultEntities.push({
...existingEntity,
length: newEntityStart - existingEntityStart,
});
resultEntities.push({
...existingEntity,
offset: newEntityStart,
length: existingEntityEnd - newEntityStart,
});
}
// If end edge overlaps
if (existingEntityStart < newEntityEnd
&& existingEntityEnd > newEntityEnd) {
// Split entity in two
resultEntities.push({
...existingEntity,
offset: newEntityEnd,
length: existingEntityEnd - newEntityStart - newEntity.length,
});
resultEntities.push({
...existingEntity,
length: newEntityEnd - existingEntityStart,
});
}
}
resultEntities.push(newEntity);
// Sort entities by offset, longer entities first
return resultEntities.sort((a, b) => a.offset - b.offset || b.length - a.length);
}
// Organize entities in a tree-like structure to better represent how the text will be displayed
function organizeEntities(entities: ApiMessageEntity[]) {
const organizedEntityIndexes: Set<number> = new Set();
@ -381,7 +424,6 @@ function processEntity({
entityContent,
nestedEntityContent,
highlight,
focusedQuote,
containerId,
asPreview,
isProtected,
@ -430,7 +472,6 @@ function processEntity({
return renderMessagePart({
content: renderedContent,
highlight,
focusedQuote,
emojiSize,
asPreview,
});
@ -613,6 +654,10 @@ function processEntity({
noPlay={noCustomEmojiPlayback}
/>
);
case ApiMessageEntityTypes.QuoteFocus:
return (
<span className="matching-text-highlight is-quote">{renderNestedMessagePart()}</span>
);
default:
return renderNestedMessagePart();
}

View File

@ -159,6 +159,7 @@ type StateProps = {
};
const selection = window.getSelection();
const UNQUOTABLE_OFFSET = -1;
const ContextMenuContainer: FC<OwnProps & StateProps> = ({
threadId,
@ -272,7 +273,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
const [canQuoteSelection, setCanQuoteSelection] = useState(false);
const [selectionQuoteOffset, setSelectionQuoteOffset] = useState(UNQUOTABLE_OFFSET);
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, onClose, message.date);
// `undefined` indicates that emoji are present and loading
@ -349,7 +350,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (isMessageTranslated) {
setCanQuoteSelection(false);
setSelectionQuoteOffset(UNQUOTABLE_OFFSET);
return;
}
@ -359,7 +360,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
&& isSelectionRangeInsideMessage(selectionRange);
if (!isMessageTextSelected) {
setCanQuoteSelection(false);
setSelectionQuoteOffset(UNQUOTABLE_OFFSET);
return;
}
@ -367,10 +368,14 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const messageText = message.content.text!.text!.replace(/\u00A0/g, ' ');
setCanQuoteSelection(
selectionText.text.trim().length > 0
&& messageText.includes(selectionText.text),
);
const canQuote = selectionText.text.trim().length > 0
&& messageText.includes(selectionText.text);
if (!canQuote) {
setSelectionQuoteOffset(UNQUOTABLE_OFFSET);
return;
}
setSelectionQuoteOffset(selectionRange.startOffset);
}, [
selectionRange, selectionRange?.collapsed, selectionRange?.startOffset, selectionRange?.endOffset,
isMessageTranslated, message.content.text,
@ -400,13 +405,17 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
});
const handleReply = useLastCallback(() => {
const quoteText = canQuoteSelection && selectionRange ? getSelectionAsFormattedText(selectionRange) : undefined;
const quoteText = selectionQuoteOffset !== UNQUOTABLE_OFFSET && selectionRange
? getSelectionAsFormattedText(selectionRange) : undefined;
if (!canReplyInChat) {
openReplyMenu({ fromChatId: message.chatId, messageId: message.id, quoteText });
openReplyMenu({
fromChatId: message.chatId, messageId: message.id, quoteText, quoteOffset: selectionQuoteOffset,
});
} else {
updateDraftReplyInfo({
replyToMsgId: message.id,
quoteText,
quoteOffset: selectionQuoteOffset,
replyToPeerId: undefined,
});
}
@ -647,7 +656,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canSendNow={canSendNow}
canReschedule={canReschedule}
canReply={canReply}
canQuote={canQuoteSelection}
canQuote={selectionQuoteOffset !== UNQUOTABLE_OFFSET}
canDelete={canDelete}
canPin={canPin}
canReport={canReport}

View File

@ -245,6 +245,7 @@ type StateProps = {
isFocused?: boolean;
focusDirection?: FocusDirection;
focusedQuote?: string;
focusedQuoteOffset?: number;
noFocusHighlight?: boolean;
scrollTargetPosition?: ScrollTargetPosition;
isResizingContainer?: boolean;
@ -368,6 +369,7 @@ const Message: FC<OwnProps & StateProps> = ({
isFocused,
focusDirection,
focusedQuote,
focusedQuoteOffset,
noFocusHighlight,
scrollTargetPosition,
isResizingContainer,
@ -971,6 +973,7 @@ const Message: FC<OwnProps & StateProps> = ({
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
isForAnimation={isForAnimation}
focusedQuote={focusedQuote}
focusedQuoteOffset={focusedQuoteOffset}
emojiSize={emojiSize}
highlight={highlight}
isProtected={isProtected}
@ -1788,7 +1791,7 @@ export default memo(withGlobal<OwnProps>(
const {
direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer,
quote: focusedQuote, scrollTargetPosition,
quote: focusedQuote, quoteOffset: focusedQuoteOffset, scrollTargetPosition,
} = (isFocused && focusedMessage) || {};
const middleSearch = selectCurrentMiddleSearch(global);
@ -1935,6 +1938,7 @@ export default memo(withGlobal<OwnProps>(
noFocusHighlight,
isResizingContainer,
focusedQuote,
focusedQuoteOffset,
scrollTargetPosition,
}),
senderBoosts,

View File

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

View File

@ -138,7 +138,9 @@ export default function useOuterHandlers(
function handleContainerDoubleClick() {
if (IS_TOUCH_ENV || !canReply) return;
updateDraftReplyInfo({ replyToMsgId: messageId, replyToPeerId: undefined, quoteText: undefined });
updateDraftReplyInfo({
replyToMsgId: messageId, replyToPeerId: undefined, quoteText: undefined, quoteOffset: undefined,
});
}
function stopPropagation(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {

View File

@ -2145,6 +2145,7 @@ addActionHandler('openChatOrTopicWithReplyInDraft', (global, actions, payload):
replyToTopId: replyingInfo.toThreadId,
replyToPeerId: currentChatId,
quoteText: replyingInfo.quoteText,
quoteOffset: replyingInfo.quoteOffset,
} as ApiInputMessageReplyInfo;
const currentReplyInfo = replyingInfo.messageId

View File

@ -405,8 +405,8 @@ 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, quote, scrollTargetPosition,
timestamp, tabId = getCurrentTabId(),
replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote, quoteOffset,
scrollTargetPosition, timestamp, tabId = getCurrentTabId(),
} = payload;
let { messageId } = payload;
@ -455,6 +455,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
noHighlight,
isResizingContainer,
quote,
quoteOffset,
scrollTargetPosition,
}, tabId);
global = updateFocusDirection(global, undefined, tabId);
@ -525,13 +526,14 @@ addActionHandler('setShouldPreventComposerAnimation', (global, actions, payload)
addActionHandler('openReplyMenu', (global, actions, payload): ActionReturnType => {
const {
fromChatId, messageId, quoteText, tabId = getCurrentTabId(),
fromChatId, messageId, quoteText, quoteOffset, tabId = getCurrentTabId(),
} = payload;
return updateTabState(global, {
replyingMessage: {
fromChatId,
messageId,
quoteText,
quoteOffset,
},
isShareMessageModalShown: true,
}, tabId);

View File

@ -726,6 +726,7 @@ export function updateFocusedMessage<T extends GlobalState>(
noHighlight = false,
isResizingContainer = false,
quote,
quoteOffset,
scrollTargetPosition,
}: {
global: T;
@ -735,6 +736,7 @@ export function updateFocusedMessage<T extends GlobalState>(
noHighlight?: boolean;
isResizingContainer?: boolean;
quote?: string;
quoteOffset?: number;
scrollTargetPosition?: ScrollTargetPosition;
},
...[tabId = getCurrentTabId()]: TabArgs<T>
@ -748,6 +750,7 @@ export function updateFocusedMessage<T extends GlobalState>(
noHighlight,
isResizingContainer,
quote,
quoteOffset,
scrollTargetPosition,
},
}, tabId);

View File

@ -956,6 +956,7 @@ export interface ActionPayloads {
shouldReplaceHistory?: boolean;
noForumTopicPanel?: boolean;
quote?: string;
quoteOffset?: number;
scrollTargetPosition?: ScrollTargetPosition;
timestamp?: number;
} & WithTabId;
@ -1769,6 +1770,7 @@ export interface ActionPayloads {
fromChatId: string;
messageId?: number;
quoteText?: ApiFormattedText;
quoteOffset?: number;
} & WithTabId;
// Forwards

View File

@ -156,6 +156,7 @@ export type TabState = {
noHighlight?: boolean;
isResizingContainer?: boolean;
quote?: string;
quoteOffset?: number;
scrollTargetPosition?: ScrollTargetPosition;
};
@ -352,6 +353,7 @@ export type TabState = {
fromChatId?: string;
messageId?: number;
quoteText?: ApiFormattedText;
quoteOffset?: number;
toChatId?: string;
toThreadId?: ThreadId;
};