diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index b53fb7b68c..f8a9dcace0 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -26,11 +26,11 @@ import MarkdownTable from './markdown_table'; import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell'; import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row'; -import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform'; +import {addListItemIndices, combineTextNodes, highlightMentions, highlightSearchPatterns, pullOutImages} from './transform'; import type { MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer, - MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, UserMentionKey, + MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, } from '@typings/global/markdown'; type MarkdownProps = { @@ -55,6 +55,7 @@ type MarkdownProps = { minimumHashtagLength?: number; onPostPress?: (event: GestureResponderEvent) => void; postId?: string; + searchPatterns?: SearchPattern[]; textStyles?: MarkdownTextStyles; theme: Theme; value?: string | number; @@ -114,7 +115,7 @@ const Markdown = ({ disableAtChannelMentionHighlight = false, disableAtMentions = false, disableChannelLink = false, disableGallery = false, disableHashtags = false, enableInlineLatex, enableLatex, imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutWidth, - location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, + location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns, textStyles = {}, theme, value = '', }: MarkdownProps) => { const style = getStyleSheet(theme); @@ -464,6 +465,7 @@ const Markdown = ({ table_cell: renderTableCell, mention_highlight: Renderer.forwardChildren, + search_highlight: Renderer.forwardChildren, editedIndicator: renderEditedIndicator, }; @@ -485,6 +487,9 @@ const Markdown = ({ if (mentionKeys) { ast = highlightMentions(ast, mentionKeys); } + if (searchPatterns) { + ast = highlightSearchPatterns(ast, searchPatterns); + } if (isEdited) { const editIndicatorNode = new Node('edited_indicator'); diff --git a/app/components/markdown/transform.test.ts b/app/components/markdown/transform.test.ts index ab487325ac..0b5138584b 100644 --- a/app/components/markdown/transform.test.ts +++ b/app/components/markdown/transform.test.ts @@ -8,12 +8,15 @@ import {Node, Parser} from 'commonmark'; import { addListItemIndices, combineTextNodes, - getFirstMention, + getFirstMatch, highlightMentions, highlightTextNode, + mentionKeysToPatterns, pullOutImages, } from '@components/markdown/transform'; +import type {UserMentionKey} from '@typings/global/markdown'; + /* eslint-disable max-lines, no-console, no-underscore-dangle */ describe('Components.Markdown.transform', () => { @@ -2623,64 +2626,69 @@ describe('Components.Markdown.transform', () => { name: 'no mention keys', input: 'apple banana orange', mentionKeys: [], - expected: {index: -1, mention: null}, + expected: {index: -1, length: -1}, }, { name: 'single mention', input: 'apple banana orange', mentionKeys: [{key: 'banana'}], - expected: {index: 6, mention: {key: 'banana'}}, + expected: {index: 6, length: 6}, }, { name: 'multiple mentions', input: 'apple banana orange', mentionKeys: [{key: 'apple'}, {key: 'orange'}], - expected: {index: 0, mention: {key: 'apple'}}, + expected: {index: 0, length: 5}, }, { name: 'case sensitive', input: 'apple APPLE Apple aPPle', mentionKeys: [{key: 'Apple', caseSensitive: true}], - expected: {index: 12, mention: {key: 'Apple', caseSensitive: true}}, + expected: {index: 12, length: 5}, }, { name: 'followed by period', input: 'banana.', mentionKeys: [{key: 'banana'}], - expected: {index: 0, mention: {key: 'banana'}}, + expected: {index: 0, length: 6}, }, { name: 'followed by underscores', input: 'banana__', mentionKeys: [{key: 'banana'}], - expected: {index: 0, mention: {key: 'banana'}}, + expected: {index: 0, length: 6}, }, { name: 'in brackets', input: '(banana)', mentionKeys: [{key: 'banana'}], - expected: {index: 1, mention: {key: 'banana'}}, + expected: {index: 1, length: 6}, }, { name: 'following punctuation', input: ':banana', mentionKeys: [{key: 'banana'}], - expected: {index: 1, mention: {key: 'banana'}}, + expected: {index: 1, length: 6}, }, { name: 'not part of another word', input: 'pineapple', mentionKeys: [{key: 'apple'}], - expected: {index: -1, mention: null}, + expected: {index: -1, length: -1}, }, { name: 'no error from weird mention keys', input: 'apple banana orange', mentionKeys: [{key: '*\\3_.'}], - expected: {index: -1, mention: null}, + expected: {index: -1, length: -1}, }, { name: 'no blank mention keys', input: 'apple banana orange', mentionKeys: [{key: ''}], - expected: {index: -1, mention: null}, + expected: {index: -1, length: -1}, }, { name: 'multibyte key', input: '좋은 하루 되세요.', mentionKeys: [{key: '하루'}], - expected: {index: 3, mention: {key: '하루'}}, + expected: {index: 3, length: 2}, }]; + function getFirstMention(str: string, mentionKeys: UserMentionKey[]) { + const patterns = mentionKeysToPatterns(mentionKeys); + return getFirstMatch(str, patterns); + } + for (const test of tests) { it(test.name, () => { const actual = getFirstMention(test.input, test.mentionKeys); diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts index 18aaa20960..b6413dda86 100644 --- a/app/components/markdown/transform.ts +++ b/app/components/markdown/transform.ts @@ -5,7 +5,7 @@ import {Node, NodeType} from 'commonmark'; import {escapeRegex} from '@utils/markdown'; -import type {UserMentionKey} from '@typings/global/markdown'; +import type {SearchPattern, UserMentionKey} from '@typings/global/markdown'; /* eslint-disable no-underscore-dangle */ @@ -114,6 +114,8 @@ function pullOutImage(image: any) { export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) { const walker = ast.walker(); + const patterns = mentionKeysToPatterns(mentionKeys); + let e; while ((e = walker.next())) { if (!e.entering) { @@ -123,13 +125,13 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) { const node = e.node; if (node.type === 'text' && node.literal) { - const {index, mention} = getFirstMention(node.literal, mentionKeys); + const {index, length} = getFirstMatch(node.literal, patterns); - if (index === -1 || !mention) { + if (index === -1) { continue; } - const mentionNode = highlightTextNode(node, index, index + mention.key.length, 'mention_highlight'); + const mentionNode = highlightTextNode(node, index, index + length, 'mention_highlight'); // Resume processing on the next node after the mention node which may include any remaining text // that was part of this one @@ -158,38 +160,71 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) { return ast; } -// Given a string and an array of mention keys, returns the first mention that appears and its index. -export function getFirstMention(str: string, mentionKeys: UserMentionKey[]) { - let firstMention = null; - let firstMentionIndex = -1; - - for (const mention of mentionKeys) { - if (mention.key.trim() === '') { - continue; - } - +export function mentionKeysToPatterns(mentionKeys: UserMentionKey[]) { + return mentionKeys.filter((mention) => mention.key.trim() !== '').map((mention) => { const flags = mention.caseSensitive ? '' : 'i'; let pattern; if (cjkPattern.test(mention.key)) { pattern = new RegExp(`${escapeRegex(mention.key)}`, flags); } else { - pattern = new RegExp(`\\b${escapeRegex(mention.key)}_*\\b`, flags); + pattern = new RegExp(`\\b${escapeRegex(mention.key)}(?=_*\\b)`, flags); } + return pattern; + }); +} + +export function highlightSearchPatterns(ast: Node, searchPatterns: SearchPattern[]) { + const walker = ast.walker(); + + let e; + while ((e = walker.next())) { + if (!e.entering) { + continue; + } + + const node = e.node; + + if (node.type === 'text' && node.literal) { + const {index, length} = getFirstMatch(node.literal, searchPatterns.map((pattern) => pattern.pattern)); + + // TODO we might need special handling here for if the search term is part of a hashtag + + if (index === -1) { + continue; + } + + const matchNode = highlightTextNode(node, index, index + length, 'search_highlight'); + + // Resume processing on the next node after the match node which may include any remaining text + // that was part of this one + walker.resumeAt(matchNode, false); + } + } + + return ast; +} + +// Given a string and an array of regexess, returns the index and length of the first match. +export function getFirstMatch(str: string, patterns: RegExp[]) { + let firstMatchIndex = -1; + let firstMatchLength = -1; + + for (const pattern of patterns) { const match = pattern.exec(str); if (!match || match[0] === '') { continue; } - if (firstMentionIndex === -1 || match.index < firstMentionIndex) { - firstMentionIndex = match.index; - firstMention = mention; + if (firstMatchIndex === -1 || match.index < firstMatchIndex) { + firstMatchIndex = match.index; + firstMatchLength = match[0].length; } } return { - index: firstMentionIndex, - mention: firstMention, + index: firstMatchIndex, + length: firstMatchLength, }; } diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index 8a0bbb5be6..c1718aad6d 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -50,6 +50,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); +const searchPatterns = [ + { + pattern: /\bMattermost\b/, + term: 'Mattermost', + }, + { + pattern: /\bbug\b/i, + term: 'bug', + }, + { + pattern: /\bboat/, + term: 'boat', + }, +]; + const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, theme}: MessageProps) => { const [open, setOpen] = useState(false); const [height, setHeight] = useState(); @@ -94,6 +109,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo textStyles={textStyles} value={post.message} mentionKeys={mentionKeys} + searchPatterns={searchPatterns} theme={theme} /> diff --git a/app/utils/markdown/index.ts b/app/utils/markdown/index.ts index ca4295fbc6..604d995b41 100644 --- a/app/utils/markdown/index.ts +++ b/app/utils/markdown/index.ts @@ -108,6 +108,11 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => { backgroundColor: theme.mentionHighlightBg, color: theme.mentionHighlightLink, }, + search_highlight: { + fontFamily: 'OpenSans', + backgroundColor: theme.mentionHighlightBg, + color: theme.mentionHighlightLink, + }, }; }); diff --git a/patches/@types+commonmark+0.27.5.patch b/patches/@types+commonmark+0.27.5.patch index 87b658f350..f25edeae83 100644 --- a/patches/@types+commonmark+0.27.5.patch +++ b/patches/@types+commonmark+0.27.5.patch @@ -8,7 +8,7 @@ index 35e9ed6..382cf98 100755 export type NodeType = - 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | - 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block'; -+ 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | 'mention_highlight' | 'at_mention' | ++ 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | 'mention_highlight' | 'search_highlight' | 'at_mention' | + 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block' | 'table' | 'edited_indicator'; export class Node { diff --git a/types/global/markdown.ts b/types/global/markdown.ts index f76b89d9ff..a478b1128f 100644 --- a/types/global/markdown.ts +++ b/types/global/markdown.ts @@ -3,7 +3,12 @@ import {TextStyle, ViewStyle} from 'react-native'; -export type UserMentionKey= { +export type SearchPattern = { + pattern: RegExp; + term: string; +}; + +export type UserMentionKey = { key: string; caseSensitive?: boolean; };