From 1b99970046d1b976eee9224b3152e41cc81156d0 Mon Sep 17 00:00:00 2001 From: srijna Date: Tue, 16 Dec 2025 20:12:51 +0530 Subject: [PATCH] feat(search): add search term highlighting in search messages --- packages/markups/src/elements/BoldSpan.js | 4 + packages/markups/src/elements/CodeElement.js | 24 +- .../markups/src/elements/HighlightSpan.js | 18 ++ .../markups/src/elements/InlineElements.js | 4 + packages/markups/src/elements/ItalicSpan.js | 4 + packages/markups/src/elements/LinkSpan.js | 4 + packages/markups/src/elements/StrikeSpan.js | 4 + packages/react/src/lib/highlightUtils.js | 263 ++++++++++++++++++ .../MessageAggregators/SearchMessages.js | 1 + .../common/MessageAggregator.js | 17 +- 10 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 packages/markups/src/elements/HighlightSpan.js create mode 100644 packages/react/src/lib/highlightUtils.js diff --git a/packages/markups/src/elements/BoldSpan.js b/packages/markups/src/elements/BoldSpan.js index 36f1ee9450..370734514e 100644 --- a/packages/markups/src/elements/BoldSpan.js +++ b/packages/markups/src/elements/BoldSpan.js @@ -4,6 +4,7 @@ import PlainSpan from './PlainSpan'; import ItalicSpan from './ItalicSpan'; import StrikeSpan from './StrikeSpan'; import LinkSpan from './LinkSpan'; +import HighlightSpan from './HighlightSpan'; const BoldSpan = ({ contents }) => ( @@ -30,6 +31,9 @@ const BoldSpan = ({ contents }) => ( /> ); + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/CodeElement.js b/packages/markups/src/elements/CodeElement.js index 8a2483f17f..da84f30141 100644 --- a/packages/markups/src/elements/CodeElement.js +++ b/packages/markups/src/elements/CodeElement.js @@ -1,15 +1,29 @@ import React from 'react'; import PropTypes from 'prop-types'; import PlainSpan from './PlainSpan'; +import HighlightSpan from './HighlightSpan'; import { InlineElementsStyles } from './elements.styles'; const CodeElement = ({ contents }) => { const styles = InlineElementsStyles(); - return ( - - - - ); + + // Handle highlighted content (array of tokens) or plain string + const renderContents = () => { + if (contents.hasHighlight && Array.isArray(contents.value)) { + return contents.value.map((token, index) => { + switch (token.type) { + case 'HIGHLIGHT_TEXT': + return ; + case 'PLAIN_TEXT': + default: + return ; + } + }); + } + return ; + }; + + return {renderContents()}; }; export default CodeElement; diff --git a/packages/markups/src/elements/HighlightSpan.js b/packages/markups/src/elements/HighlightSpan.js new file mode 100644 index 0000000000..16ef56d212 --- /dev/null +++ b/packages/markups/src/elements/HighlightSpan.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { css } from '@emotion/react'; + +const highlightStyle = css` + background-color: yellow; + color: black; +`; + +const HighlightSpan = ({ contents }) => ( + {contents} +); + +export default HighlightSpan; + +HighlightSpan.propTypes = { + contents: PropTypes.string, +}; diff --git a/packages/markups/src/elements/InlineElements.js b/packages/markups/src/elements/InlineElements.js index 2584196e89..3981be60fc 100644 --- a/packages/markups/src/elements/InlineElements.js +++ b/packages/markups/src/elements/InlineElements.js @@ -11,6 +11,7 @@ import ColorElement from './ColorElement'; import LinkSpan from './LinkSpan'; import UserMention from '../mentions/UserMention'; import TimestampElement from './TimestampElement'; +import HighlightSpan from './HighlightSpan'; const InlineElements = ({ contents }) => contents.map((content, index) => { @@ -58,6 +59,9 @@ const InlineElements = ({ contents }) => case 'TIMESTAMP': return ; + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/ItalicSpan.js b/packages/markups/src/elements/ItalicSpan.js index 3f5d54a43a..f47808d494 100644 --- a/packages/markups/src/elements/ItalicSpan.js +++ b/packages/markups/src/elements/ItalicSpan.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import PlainSpan from './PlainSpan'; import BoldSpan from './BoldSpan'; import StrikeSpan from './StrikeSpan'; +import HighlightSpan from './HighlightSpan'; const ItalicSpan = ({ contents }) => ( @@ -17,6 +18,9 @@ const ItalicSpan = ({ contents }) => ( case 'BOLD': return ; + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/LinkSpan.js b/packages/markups/src/elements/LinkSpan.js index 319d180130..22e5340ac8 100644 --- a/packages/markups/src/elements/LinkSpan.js +++ b/packages/markups/src/elements/LinkSpan.js @@ -5,6 +5,7 @@ import PlainSpan from './PlainSpan'; import StrikeSpan from './StrikeSpan'; import ItalicSpan from './ItalicSpan'; import BoldSpan from './BoldSpan'; +import HighlightSpan from './HighlightSpan'; const getBaseURI = () => { if (document.baseURI) { @@ -48,6 +49,9 @@ const LinkSpan = ({ href, label }) => { case 'BOLD': return ; + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/StrikeSpan.js b/packages/markups/src/elements/StrikeSpan.js index d8e749008c..1a4ce48017 100644 --- a/packages/markups/src/elements/StrikeSpan.js +++ b/packages/markups/src/elements/StrikeSpan.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import PlainSpan from './PlainSpan'; import BoldSpan from './BoldSpan'; import ItalicSpan from './ItalicSpan'; +import HighlightSpan from './HighlightSpan'; const StrikeSpan = ({ contents }) => ( @@ -17,6 +18,9 @@ const StrikeSpan = ({ contents }) => ( case 'BOLD': return ; + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/react/src/lib/highlightUtils.js b/packages/react/src/lib/highlightUtils.js new file mode 100644 index 0000000000..605829bc72 --- /dev/null +++ b/packages/react/src/lib/highlightUtils.js @@ -0,0 +1,263 @@ +/** + * Utility functions for highlighting search terms in messages + */ + +/** + * Escapes special regex characters in a string + * @param {string} str - The string to escape + * @returns {string} - The escaped string + */ +const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Highlights occurrences of searchTerm within a plain text value, + * returning an array of PLAIN_TEXT and HIGHLIGHT_TEXT tokens. + * @param {string} text - The text to search within + * @param {string} searchTerm - The term to highlight + * @returns {{ tokens: Array, matchCount: number }} + */ +const highlightInText = (text, searchTerm) => { + if (!text || !searchTerm) { + return { tokens: [{ type: 'PLAIN_TEXT', value: text || '' }], matchCount: 0 }; + } + + const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi'); + const parts = text.split(regex); + let matchCount = 0; + const tokens = []; + + parts.forEach((part) => { + if (!part) return; + if (part.toLowerCase() === searchTerm.toLowerCase()) { + tokens.push({ type: 'HIGHLIGHT_TEXT', value: part }); + matchCount += 1; + } else { + tokens.push({ type: 'PLAIN_TEXT', value: part }); + } + }); + + return { tokens, matchCount }; +}; + +/** + * Recursively processes tokens to inject HIGHLIGHT_TEXT tokens + * @param {Array} tokens - Array of markdown tokens + * @param {string} searchTerm - The term to highlight + * @returns {{ tokens: Array, matchCount: number }} + */ +const highlightInTokens = (tokens, searchTerm) => { + if (!Array.isArray(tokens)) { + return { tokens: [], matchCount: 0 }; + } + + let totalMatchCount = 0; + const processedTokens = []; + + tokens.forEach((token) => { + if (!token) return; + + switch (token.type) { + case 'PLAIN_TEXT': { + const { tokens: highlighted, matchCount } = highlightInText( + token.value, + searchTerm + ); + processedTokens.push(...highlighted); + totalMatchCount += matchCount; + break; + } + + case 'BOLD': + case 'ITALIC': + case 'STRIKE': { + if (Array.isArray(token.value)) { + const { tokens: innerTokens, matchCount } = highlightInTokens( + token.value, + searchTerm + ); + processedTokens.push({ ...token, value: innerTokens }); + totalMatchCount += matchCount; + } else if (typeof token.value === 'string') { + const { tokens: highlighted, matchCount } = highlightInText( + token.value, + searchTerm + ); + processedTokens.push({ ...token, value: highlighted }); + totalMatchCount += matchCount; + } else { + processedTokens.push(token); + } + break; + } + + case 'LINK': { + if (token.value && Array.isArray(token.value.label)) { + const { tokens: labelTokens, matchCount } = highlightInTokens( + token.value.label, + searchTerm + ); + processedTokens.push({ + ...token, + value: { ...token.value, label: labelTokens }, + }); + totalMatchCount += matchCount; + } else { + processedTokens.push(token); + } + break; + } + + case 'INLINE_CODE': { + const codeText = typeof token.value === 'string' ? token.value : ''; + const { tokens: highlighted, matchCount } = highlightInText( + codeText, + searchTerm + ); + if (matchCount > 0) { + processedTokens.push({ ...token, value: highlighted, hasHighlight: true }); + } else { + processedTokens.push(token); + } + totalMatchCount += matchCount; + break; + } + + default: + processedTokens.push(token); + break; + } + }); + + return { tokens: processedTokens, matchCount: totalMatchCount }; +}; + +/** + * Processes block-level tokens (PARAGRAPH, HEADING, etc.) to inject highlighting + * @param {Array} md - Array of block-level tokens + * @param {string} searchTerm - The term to highlight + * @returns {{ md: Array, matchCount: number }} + */ +const highlightInMd = (md, searchTerm) => { + if (!Array.isArray(md)) { + return { md: [], matchCount: 0 }; + } + + let totalMatchCount = 0; + const processedMd = md.map((block) => { + if (!block) return block; + + switch (block.type) { + case 'PARAGRAPH': + case 'HEADING': { + const { tokens, matchCount } = highlightInTokens(block.value, searchTerm); + totalMatchCount += matchCount; + return { ...block, value: tokens }; + } + + case 'UNORDERED_LIST': + case 'ORDERED_LIST': { + const processedItems = block.value.map((item) => { + const { tokens, matchCount } = highlightInTokens(item.value, searchTerm); + totalMatchCount += matchCount; + return { ...item, value: tokens }; + }); + return { ...block, value: processedItems }; + } + + case 'QUOTE': { + const { md: innerMd, matchCount } = highlightInMd(block.value, searchTerm); + totalMatchCount += matchCount; + return { ...block, value: innerMd }; + } + + case 'CODE': { + // For code blocks, check each line + if (Array.isArray(block.value)) { + let codeMatchCount = 0; + const processedLines = block.value.map((line) => { + if (typeof line.value === 'string') { + const { tokens, matchCount } = highlightInText(line.value, searchTerm); + codeMatchCount += matchCount; + if (matchCount > 0) { + return { ...line, value: tokens, hasHighlight: true }; + } + } + return line; + }); + totalMatchCount += codeMatchCount; + return { ...block, value: processedLines }; + } + return block; + } + + default: + return block; + } + }); + + return { md: processedMd, matchCount: totalMatchCount }; +}; + +/** + * Applies search term highlighting to a message. + * If message has `md`, injects HIGHLIGHT_TEXT tokens. + * If message only has `msg`, creates basic PARAGRAPH tokens with highlighting. + * Returns a NEW message object (does not mutate original). + * + * @param {Object} message - The message object + * @param {string} searchTerm - The term to highlight + * @returns {{ message: Object, matchCount: number }} + */ +export const applyHighlightToMessage = (message, searchTerm) => { + if (!message || !searchTerm || !searchTerm.trim()) { + return { message, matchCount: 0 }; + } + + const term = searchTerm.trim(); + + // If message has md tokens, process them + if (message.md && Array.isArray(message.md)) { + const { md, matchCount } = highlightInMd(message.md, term); + return { + message: { ...message, md }, + matchCount, + }; + } + + // If message only has msg (plain text), create basic md structure + if (message.msg && typeof message.msg === 'string') { + const { tokens, matchCount } = highlightInText(message.msg, term); + return { + message: { + ...message, + md: [{ type: 'PARAGRAPH', value: tokens }], + }, + matchCount, + }; + } + + return { message, matchCount: 0 }; +}; + +/** + * Applies highlighting to an array of messages. + * Returns NEW array with NEW message objects (does not mutate originals). + * + * @param {Array} messages - Array of message objects + * @param {string} searchTerm - The term to highlight + * @returns {{ messages: Array, totalMatchCount: number }} + */ +export const applyHighlightToMessages = (messages, searchTerm) => { + if (!Array.isArray(messages) || !searchTerm || !searchTerm.trim()) { + return { messages: messages || [], totalMatchCount: 0 }; + } + + let totalMatchCount = 0; + const highlightedMessages = messages.map((msg) => { + const { message, matchCount } = applyHighlightToMessage(msg, searchTerm); + totalMatchCount += matchCount; + return message; + }); + + return { messages: highlightedMessages, totalMatchCount }; +}; diff --git a/packages/react/src/views/MessageAggregators/SearchMessages.js b/packages/react/src/views/MessageAggregators/SearchMessages.js index b4248461c6..ffab3f7195 100644 --- a/packages/react/src/views/MessageAggregators/SearchMessages.js +++ b/packages/react/src/views/MessageAggregators/SearchMessages.js @@ -49,6 +49,7 @@ const SearchMessages = () => { isSearch: true, handleInputChange, placeholder: 'Search Messages', + searchedText: text, }} searchFiltered={messageList} shouldRender={(msg) => !!msg} diff --git a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js index ab8c3bc2f0..d729b822ce 100644 --- a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js +++ b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js @@ -20,6 +20,7 @@ import NoMessagesIndicator from './NoMessageIndicator'; import FileDisplay from '../../FileMessage/FileMessage'; import useSetExclusiveState from '../../../hooks/useSetExclusiveState'; import { useRCContext } from '../../../context/RCInstance'; +import { applyHighlightToMessages } from '../../../lib/highlightUtils'; export const MessageAggregator = ({ title, @@ -48,8 +49,22 @@ export const MessageAggregator = ({ ); const [messageRendered, setMessageRendered] = useState(false); + + // Apply highlighting to search results when searchedText is provided + const highlightedMessages = useMemo(() => { + const sourceMessages = fetchedMessageList || searchFiltered || allMessages; + if (searchFiltered && searchProps?.searchedText) { + const { messages: highlighted } = applyHighlightToMessages( + sourceMessages, + searchProps.searchedText + ); + return highlighted; + } + return sourceMessages; + }, [fetchedMessageList, searchFiltered, allMessages, searchProps?.searchedText]); + const { loading, messageList } = useSetMessageList( - fetchedMessageList || searchFiltered || allMessages, + highlightedMessages, shouldRender );