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;