Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions packages/markups/src/elements/PlainSpan.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<mark key={index} css={styles.highlight}>
{segment.text}
</mark>
) : (
<React.Fragment key={index}>{segment.text}</React.Fragment>
)
)}
</>
);
};

export default PlainSpan;

Expand Down
13 changes: 13 additions & 0 deletions packages/markups/src/elements/elements.styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}%);
Expand Down
41 changes: 41 additions & 0 deletions packages/markups/src/elements/highlightSegments.js
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions packages/react/src/context/SearchHighlightContext.js
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 7 additions & 2 deletions packages/react/src/views/Markdown/Markdown.js
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
57 changes: 57 additions & 0 deletions packages/react/src/views/Markdown/highlightSegments.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 19 additions & 13 deletions packages/react/src/views/MessageAggregators/SearchMessages.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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');
const viewType = variantOverrides.viewType || 'Sidebar';
const { RCInstance } = useContext(RCContext);
const [text, setText] = useState('');
const [messageList, setMessageList] = useState([]);
const [highlightTerm, setHighlightTerm] = useState('');

const handleInputChange = (e) => {
setText(e.target.value);
Expand All @@ -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(
Expand All @@ -32,6 +35,7 @@ const SearchMessages = () => {
if (messageList.length > 0) {
setMessageList([]);
}
setHighlightTerm('');
} else {
debouncedSearch();
}
Expand All @@ -41,19 +45,21 @@ const SearchMessages = () => {
}, [text, debouncedSearch, messageList.length]);

return (
<MessageAggregator
title="Search Messages"
iconName="magnifier"
noMessageInfo="No results found"
searchProps={{
isSearch: true,
handleInputChange,
placeholder: 'Search Messages',
}}
searchFiltered={messageList}
shouldRender={(msg) => !!msg}
viewType={viewType}
/>
<SearchHighlightContext.Provider value={highlightTerm}>
<MessageAggregator
title="Search Messages"
iconName="magnifier"
noMessageInfo="No results found"
searchProps={{
isSearch: true,
handleInputChange,
placeholder: 'Search Messages',
}}
searchFiltered={messageList}
shouldRender={(msg) => !!msg}
viewType={viewType}
/>
</SearchHighlightContext.Provider>
);
};
export default SearchMessages;