diff --git a/packages/markups/src/elements/BoldSpan.js b/packages/markups/src/elements/BoldSpan.js index 36f1ee945..3e01a3353 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 HighlightText from './highlightText'; const BoldSpan = ({ contents }) => ( @@ -17,6 +18,7 @@ const BoldSpan = ({ contents }) => ( case 'ITALIC': return ; + case 'LINK': return ( ( /> ); + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/CodeElement.js b/packages/markups/src/elements/CodeElement.js index 8a2483f17..6f5eaba7a 100644 --- a/packages/markups/src/elements/CodeElement.js +++ b/packages/markups/src/elements/CodeElement.js @@ -1,13 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import PlainSpan from './PlainSpan'; +import HighlightText from './highlightText'; import { InlineElementsStyles } from './elements.styles'; const CodeElement = ({ contents }) => { const styles = InlineElementsStyles(); + + const contentsArray = Array.isArray(contents) ? contents : [contents]; return ( - + {contentsArray.map((content, index) => { + switch (content.type) { + case 'PLAIN_TEXT': + return ; + + case 'HIGHLIGHT_TEXT': + return ; + + default: + return null; + } + })} ); }; @@ -15,5 +29,16 @@ const CodeElement = ({ contents }) => { export default CodeElement; CodeElement.propTypes = { - contents: PropTypes.any, + contents: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.string.isRequired, + value: PropTypes.any.isRequired, + }) + ), + PropTypes.shape({ + type: PropTypes.string.isRequired, + value: PropTypes.any.isRequired, + }), + ]).isRequired, }; diff --git a/packages/markups/src/elements/InlineElements.js b/packages/markups/src/elements/InlineElements.js index 0ab5a7fb1..dfb6fa588 100644 --- a/packages/markups/src/elements/InlineElements.js +++ b/packages/markups/src/elements/InlineElements.js @@ -10,6 +10,7 @@ import ChannelMention from '../mentions/ChannelMention'; import ColorElement from './ColorElement'; import LinkSpan from './LinkSpan'; import UserMention from '../mentions/UserMention'; +import HighlightText from './highlightText'; const InlineElements = ({ contents }) => contents.map((content, index) => { @@ -53,6 +54,10 @@ const InlineElements = ({ contents }) => } /> ); + + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/ItalicSpan.js b/packages/markups/src/elements/ItalicSpan.js index 3f5d54a43..c82410ab4 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 HighlightText from './highlightText'; 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 b1257f98d..39c5a6479 100644 --- a/packages/markups/src/elements/LinkSpan.js +++ b/packages/markups/src/elements/LinkSpan.js @@ -4,6 +4,7 @@ import PlainSpan from './PlainSpan'; import StrikeSpan from './StrikeSpan'; import ItalicSpan from './ItalicSpan'; import BoldSpan from './BoldSpan'; +import HighlightText from './highlightText'; const getBaseURI = () => { if (document.baseURI) { @@ -44,6 +45,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 d8e749008..855a7912a 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 HighlightText from './highlightText'; const StrikeSpan = ({ contents }) => ( @@ -17,6 +18,9 @@ const StrikeSpan = ({ contents }) => ( case 'BOLD': return ; + case 'HIGHLIGHT_TEXT': + return ; + default: return null; } diff --git a/packages/markups/src/elements/elements.styles.js b/packages/markups/src/elements/elements.styles.js index 31497a651..1d623c8a0 100644 --- a/packages/markups/src/elements/elements.styles.js +++ b/packages/markups/src/elements/elements.styles.js @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { useTheme, darken } from '@embeddedchat/ui-elements'; +import { useTheme, darken, lighten } from '@embeddedchat/ui-elements'; export const InlineElementsStyles = () => { const { theme } = useTheme(); @@ -81,6 +81,19 @@ export const EmojiStyles = { `, }; +export const useHighlightTextStyles = (theme, mode) => { + const styles = { + highlight: css` + background-color: ${mode === 'light' + ? lighten(theme.colors.warning, 0.25) + : darken(theme.colors.warningForeground, 0.3)}; + font-weight: bold; + `, + }; + + return styles; +}; + const useMentionStyles = (contents, username) => { const { theme } = useTheme(); const styles = { diff --git a/packages/markups/src/elements/highlightText.js b/packages/markups/src/elements/highlightText.js new file mode 100644 index 000000000..990f48007 --- /dev/null +++ b/packages/markups/src/elements/highlightText.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, useTheme } from '@embeddedchat/ui-elements'; +import { useHighlightTextStyles } from './elements.styles'; + +const HighlightText = ({ contents }) => { + const { theme } = useTheme(); + const { mode } = useTheme(); + const styles = useHighlightTextStyles(theme, mode); + + return ( + + {contents}{' '} + + ); +}; + +HighlightText.propTypes = { + contents: PropTypes.string.isRequired, +}; + +export default HighlightText; diff --git a/packages/react/src/lib/highlightUtils.js b/packages/react/src/lib/highlightUtils.js new file mode 100644 index 000000000..a8292d9c4 --- /dev/null +++ b/packages/react/src/lib/highlightUtils.js @@ -0,0 +1,151 @@ +const highlightText = (text, searchTerm) => { + const parts = text.split(new RegExp(`(${searchTerm})`, 'gi')); + const result = []; + + parts.forEach((part) => { + if (part.toLowerCase() === searchTerm.toLowerCase()) { + result.push({ type: 'HIGHLIGHT_TEXT', value: part }); + } else { + result.push({ type: 'PLAIN_TEXT', value: part }); + } + }); + + return result; +}; + +export function highlightSearchTerm(messagesArr, searchedWords) { + const searchTerms = Array.isArray(searchedWords) + ? searchedWords + : [searchedWords]; + + return messagesArr.map((message) => { + if (message.md) { + message.md = message.md.map((paragraphBlock) => { + if (paragraphBlock.type === 'PARAGRAPH') { + const updatedValue = paragraphBlock.value.reduce( + (accumulatedValue, content) => { + if (content.type === 'PLAIN_TEXT') { + let updatedContent = content.value; + searchTerms.forEach((searchTerm) => { + if (searchTerm && updatedContent) { + accumulatedValue.push( + ...highlightText(updatedContent, searchTerm) + ); + updatedContent = ''; + } + }); + + if (updatedContent) { + accumulatedValue.push({ + type: 'PLAIN_TEXT', + value: updatedContent, + }); + } + } else if (content.type === 'LINK') { + const updatedLabel = content.value.label.reduce( + (labelAccumulatedValue, labelContent) => { + if (labelContent.type === 'PLAIN_TEXT') { + let updatedLabelContent = labelContent.value; + searchTerms.forEach((searchTerm) => { + if (searchTerm && updatedLabelContent) { + labelAccumulatedValue.push( + ...highlightText(updatedLabelContent, searchTerm) + ); + updatedLabelContent = ''; + } + }); + + if (updatedLabelContent) { + labelAccumulatedValue.push({ + type: 'PLAIN_TEXT', + value: updatedLabelContent, + }); + } + } else { + labelAccumulatedValue.push(labelContent); + } + + return labelAccumulatedValue; + }, + [] + ); + + accumulatedValue.push({ + ...content, + value: { + ...content.value, + label: updatedLabel, + }, + }); + } else if (content.type === 'INLINE_CODE') { + let updatedContent = content.value.value; + searchTerms.forEach((searchTerm) => { + if (searchTerm && updatedContent) { + accumulatedValue.push( + ...highlightText(updatedContent, searchTerm) + ); + updatedContent = ''; + } + }); + + if (updatedContent) { + accumulatedValue.push({ + type: 'INLINE_CODE', + value: { type: 'PLAIN_TEXT', value: updatedContent }, + }); + } + } else if (['STRIKE', 'BOLD', 'ITALIC'].includes(content.type)) { + const updatedContents = content.value.reduce( + (innerAccumulatedValue, innerContent) => { + if (innerContent.type === 'PLAIN_TEXT') { + let updatedContent = innerContent.value; + searchTerms.forEach((searchTerm) => { + if (searchTerm && updatedContent) { + innerAccumulatedValue.push( + ...highlightText(updatedContent, searchTerm) + ); + updatedContent = ''; + } + }); + + if (updatedContent) { + innerAccumulatedValue.push({ + type: 'PLAIN_TEXT', + value: updatedContent, + }); + } + } else { + innerAccumulatedValue.push(innerContent); + } + + return innerAccumulatedValue; + }, + [] + ); + + accumulatedValue.push({ + ...content, + value: updatedContents, + }); + } else { + accumulatedValue.push(content); + } + + return accumulatedValue; + }, + [] + ); + + return { + ...paragraphBlock, + value: updatedValue, + }; + } + + return paragraphBlock; + }); + } + + return message; + }); +} diff --git a/packages/react/src/views/MessageAggregators/SearchMessages.js b/packages/react/src/views/MessageAggregators/SearchMessages.js index b4248461c..eb63f1db5 100644 --- a/packages/react/src/views/MessageAggregators/SearchMessages.js +++ b/packages/react/src/views/MessageAggregators/SearchMessages.js @@ -53,6 +53,7 @@ const SearchMessages = () => { searchFiltered={messageList} shouldRender={(msg) => !!msg} viewType={viewType} + searchedText={text} /> ); }; diff --git a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js index 44461790a..e4858bb91 100644 --- a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js +++ b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { isSameDay, format, set } from 'date-fns'; +import { isSameDay, format } from 'date-fns'; import { Box, Sidebar, @@ -18,6 +18,7 @@ import NoMessagesIndicator from './NoMessageIndicator'; import FileDisplay from '../../FileMessage/FileMessage'; import useSetExclusiveState from '../../../hooks/useSetExclusiveState'; import { useRCContext } from '../../../context/RCInstance'; +import { highlightSearchTerm } from '../../../lib/highlightUtils'; export const MessageAggregator = ({ title, @@ -29,6 +30,7 @@ export const MessageAggregator = ({ searchProps, searchFiltered, fetching, + searchedText, type = 'message', viewType = 'Sidebar', }) => { @@ -45,7 +47,11 @@ export const MessageAggregator = ({ ); const [messageRendered, setMessageRendered] = useState(false); - const { loading, messageList } = useSetMessageList( + let { messageList } = useSetMessageList( + fetchedMessageList || searchFiltered || allMessages, + shouldRender + ); + const { loading } = useSetMessageList( fetchedMessageList || searchFiltered || allMessages, shouldRender ); @@ -109,6 +115,16 @@ export const MessageAggregator = ({ const noMessages = messageList?.length === 0 || !messageRendered; const ViewComponent = viewType === 'Popup' ? Popup : Sidebar; + if (title === 'Search Messages') { + if (messageList) { + const highlightedMessages = highlightSearchTerm( + messageList, + searchedText + ); + messageList = highlightedMessages; + } + } + return (