From 1e98a1df1adadddcb4629f0ced783aa9c862ad88 Mon Sep 17 00:00:00 2001 From: ishan kumar <289112142+ishan-one8@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:51:51 +0530 Subject: [PATCH] feat(react): highlight searched terms in search results --- packages/markups/src/elements/PlainSpan.js | 33 ++++++++++- .../markups/src/elements/elements.styles.js | 13 +++++ .../markups/src/elements/highlightSegments.js | 41 +++++++++++++ .../src/context/SearchHighlightContext.js | 10 ++++ packages/react/src/views/Markdown/Markdown.js | 9 ++- .../views/Markdown/highlightSegments.test.js | 57 +++++++++++++++++++ .../MessageAggregators/SearchMessages.js | 32 ++++++----- 7 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 packages/markups/src/elements/highlightSegments.js create mode 100644 packages/react/src/context/SearchHighlightContext.js create mode 100644 packages/react/src/views/Markdown/highlightSegments.test.js diff --git a/packages/markups/src/elements/PlainSpan.js b/packages/markups/src/elements/PlainSpan.js index cc52613c02..f26df883b9 100644 --- a/packages/markups/src/elements/PlainSpan.js +++ b/packages/markups/src/elements/PlainSpan.js @@ -1,7 +1,36 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; +import { MarkupInteractionContext } from '../MarkupInteractionContext'; +import highlightSegments from './highlightSegments'; +import { HighlightStyles } from './elements.styles'; -const PlainSpan = ({ contents }) => <>{contents}; +const PlainSpan = ({ contents }) => { + const { highlight } = useContext(MarkupInteractionContext) || {}; + const styles = HighlightStyles(); + + if (!highlight) { + return <>{contents}; + } + + const segments = highlightSegments(contents, highlight); + if (segments.length === 1 && !segments[0].match) { + return <>{contents}; + } + + return ( + <> + {segments.map((segment, index) => + segment.match ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + )} + + ); +}; export default PlainSpan; diff --git a/packages/markups/src/elements/elements.styles.js b/packages/markups/src/elements/elements.styles.js index 31497a6517..3aec1f4cc0 100644 --- a/packages/markups/src/elements/elements.styles.js +++ b/packages/markups/src/elements/elements.styles.js @@ -43,6 +43,19 @@ export const CodeBlockStyles = () => { return styles; }; +export const HighlightStyles = () => { + const { theme } = useTheme(); + const styles = { + highlight: css` + background-color: ${theme.colors.warning}; + color: ${theme.colors.warningForeground}; + border-radius: 2px; + padding: 0 1px; + `, + }; + return styles; +}; + export const ColorElementStyles = { colorBox: (r, g, b, a) => css` background-color: rgba(${r}, ${g}, ${b}, ${(a / 255) * 100}%); diff --git a/packages/markups/src/elements/highlightSegments.js b/packages/markups/src/elements/highlightSegments.js new file mode 100644 index 0000000000..83eaebcb13 --- /dev/null +++ b/packages/markups/src/elements/highlightSegments.js @@ -0,0 +1,41 @@ +/** + * Splits `text` around every case-insensitive occurrence of `term`. + * Returns a flat list of segments so callers can wrap only the matches. + * With no usable term the text comes back as a single unmatched segment, + * which lets callers keep their existing render path untouched. + */ +const highlightSegments = (text, term) => { + const needle = typeof term === 'string' ? term.trim().toLowerCase() : ''; + if (!needle || typeof text !== 'string' || !text) { + return [{ text, match: false }]; + } + + const haystack = text.toLowerCase(); + const segments = []; + let cursor = 0; + + for (;;) { + const found = haystack.indexOf(needle, cursor); + if (found === -1) { + break; + } + if (found > cursor) { + segments.push({ text: text.slice(cursor, found), match: false }); + } + segments.push({ + text: text.slice(found, found + needle.length), + match: true, + }); + cursor = found + needle.length; + } + + if (!segments.length) { + return [{ text, match: false }]; + } + if (cursor < text.length) { + segments.push({ text: text.slice(cursor), match: false }); + } + return segments; +}; + +export default highlightSegments; diff --git a/packages/react/src/context/SearchHighlightContext.js b/packages/react/src/context/SearchHighlightContext.js new file mode 100644 index 0000000000..40893f7c91 --- /dev/null +++ b/packages/react/src/context/SearchHighlightContext.js @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +/** + * Carries the term that produced the current search results. + * Only the search view provides it, so message rendering everywhere else + * keeps its existing behaviour. + */ +const SearchHighlightContext = createContext(''); + +export default SearchHighlightContext; diff --git a/packages/react/src/views/Markdown/Markdown.js b/packages/react/src/views/Markdown/Markdown.js index 1eae8326f8..1a97d65a90 100644 --- a/packages/react/src/views/Markdown/Markdown.js +++ b/packages/react/src/views/Markdown/Markdown.js @@ -1,15 +1,20 @@ -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; import { Box } from '@embeddedchat/ui-elements'; import { Markup, MarkupInteractionContext } from '@embeddedchat/markups/src'; import EmojiReaction from '../EmojiReaction/EmojiReaction'; import { useMemberStore, useUserStore } from '../../store'; +import SearchHighlightContext from '../../context/SearchHighlightContext'; const Markdown = ({ body, md, isReaction = false }) => { const members = useMemberStore((state) => state.members); const username = useUserStore((state) => state.username); - const value = useMemo(() => ({ members, username }), [members, username]); + const highlight = useContext(SearchHighlightContext); + const value = useMemo( + () => ({ members, username, highlight }), + [members, username, highlight] + ); if (isReaction) { return ( diff --git a/packages/react/src/views/Markdown/highlightSegments.test.js b/packages/react/src/views/Markdown/highlightSegments.test.js new file mode 100644 index 0000000000..6aa708d073 --- /dev/null +++ b/packages/react/src/views/Markdown/highlightSegments.test.js @@ -0,0 +1,57 @@ +import highlightSegments from '@embeddedchat/markups/src/elements/highlightSegments'; + +const texts = (segments) => segments.map((s) => s.text); +const matches = (segments) => segments.filter((s) => s.match).map((s) => s.text); + +describe('highlightSegments', () => { + test('returns the text untouched when there is no term', () => { + expect(highlightSegments('hello world', '')).toEqual([ + { text: 'hello world', match: false }, + ]); + expect(highlightSegments('hello world', ' ')).toEqual([ + { text: 'hello world', match: false }, + ]); + expect(highlightSegments('hello world', undefined)).toEqual([ + { text: 'hello world', match: false }, + ]); + }); + + test('returns the text untouched when the term does not occur', () => { + expect(highlightSegments('hello world', 'zzz')).toEqual([ + { text: 'hello world', match: false }, + ]); + }); + + test('splits around a match in the middle', () => { + const segments = highlightSegments('say hello there', 'hello'); + expect(texts(segments)).toEqual(['say ', 'hello', ' there']); + expect(matches(segments)).toEqual(['hello']); + }); + + test('handles a match at the start and at the end', () => { + expect(texts(highlightSegments('hello there', 'hello'))).toEqual([ + 'hello', + ' there', + ]); + expect(texts(highlightSegments('say hello', 'hello'))).toEqual([ + 'say ', + 'hello', + ]); + }); + + test('marks every occurrence, not just the first', () => { + const segments = highlightSegments('bug, bug and bug', 'bug'); + expect(matches(segments)).toEqual(['bug', 'bug', 'bug']); + }); + + test('matches case-insensitively but keeps the original casing', () => { + const segments = highlightSegments('Hello HELLO hello', 'hello'); + expect(matches(segments)).toEqual(['Hello', 'HELLO', 'hello']); + }); + + test('rejoins to exactly the original text', () => { + const input = 'Search for Bug inside this bug report'; + const segments = highlightSegments(input, 'bug'); + expect(texts(segments).join('')).toBe(input); + }); +}); diff --git a/packages/react/src/views/MessageAggregators/SearchMessages.js b/packages/react/src/views/MessageAggregators/SearchMessages.js index b4248461c6..fca917a14a 100644 --- a/packages/react/src/views/MessageAggregators/SearchMessages.js +++ b/packages/react/src/views/MessageAggregators/SearchMessages.js @@ -3,6 +3,7 @@ import debounce from 'lodash/debounce'; import { useComponentOverrides } from '@embeddedchat/ui-elements'; import RCContext from '../../context/RCInstance'; import { MessageAggregator } from './common/MessageAggregator'; +import SearchHighlightContext from '../../context/SearchHighlightContext'; const SearchMessages = () => { const { variantOverrides } = useComponentOverrides('SearchMessages'); @@ -10,6 +11,7 @@ const SearchMessages = () => { const { RCInstance } = useContext(RCContext); const [text, setText] = useState(''); const [messageList, setMessageList] = useState([]); + const [highlightTerm, setHighlightTerm] = useState(''); const handleInputChange = (e) => { setText(e.target.value); @@ -18,6 +20,7 @@ const SearchMessages = () => { const searchMessages = useCallback(async () => { const { messages } = await RCInstance.getSearchMessages(text); setMessageList(messages); + setHighlightTerm(text); }, [text, RCInstance]); const debouncedSearch = useCallback( @@ -32,6 +35,7 @@ const SearchMessages = () => { if (messageList.length > 0) { setMessageList([]); } + setHighlightTerm(''); } else { debouncedSearch(); } @@ -41,19 +45,21 @@ const SearchMessages = () => { }, [text, debouncedSearch, messageList.length]); return ( - !!msg} - viewType={viewType} - /> + + !!msg} + viewType={viewType} + /> + ); }; export default SearchMessages;