diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js index dee1ab491..8705ac28d 100644 --- a/app/components/markdown/index.js +++ b/app/components/markdown/index.js @@ -5,12 +5,14 @@ import {connect} from 'react-redux'; import {getAutolinkedUrlSchemes} from 'mattermost-redux/selectors/entities/general'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users'; import Markdown from './markdown'; function mapStateToProps(state) { return { autolinkedUrlSchemes: getAutolinkedUrlSchemes(state), + mentionKeys: getCurrentUserMentionKeys(state), theme: getTheme(state), }; } diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index bbfdb750d..47c7076e5 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -30,7 +30,12 @@ import MarkdownTable from './markdown_table'; import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow from './markdown_table_row'; import MarkdownTableCell from './markdown_table_cell'; -import {addListItemIndices, pullOutImages} from './transform'; +import { + addListItemIndices, + combineTextNodes, + highlightMentions, + pullOutImages, +} from './transform'; export default class Markdown extends PureComponent { static propTypes = { @@ -40,6 +45,7 @@ export default class Markdown extends PureComponent { isEdited: PropTypes.bool, isReplyPost: PropTypes.bool, isSearchResult: PropTypes.bool, + mentionKeys: PropTypes.array.isRequired, navigator: PropTypes.object.isRequired, onChannelLinkPress: PropTypes.func, onHashtagPress: PropTypes.func, @@ -111,6 +117,8 @@ export default class Markdown extends PureComponent { table_row: MarkdownTableRow, table_cell: MarkdownTableCell, + mention_highlight: Renderer.forwardChildren, + editedIndicator: this.renderEditedIndicator, }, renderParagraphsInLists: true, @@ -141,9 +149,10 @@ export default class Markdown extends PureComponent { // If this text is displayed, it will be styled by the image component return {literal}; } - const style = this.computeTextStyle(this.props.baseTextStyle, context); // Construct the text style based off of the parents of this node since RN's inheritance is limited + const style = this.computeTextStyle(this.props.baseTextStyle, context); + return {literal}; } @@ -390,8 +399,10 @@ export default class Markdown extends PureComponent { render() { let ast = this.parser.parse(this.props.value); + ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); + ast = highlightMentions(ast, this.props.mentionKeys); if (this.props.isEdited) { const editIndicatorNode = new Node('edited_indicator'); diff --git a/app/components/markdown/transform.js b/app/components/markdown/transform.js index 92d9f8cfe..b8fb6e342 100644 --- a/app/components/markdown/transform.js +++ b/app/components/markdown/transform.js @@ -3,8 +3,45 @@ import {Node} from 'commonmark'; +import {escapeRegex} from 'app/utils/markdown'; + /* eslint-disable no-underscore-dangle */ +// Combines adjacent text nodes into a single text node to make further transformation easier +export function combineTextNodes(ast) { + const walker = ast.walker(); + + let e; + while ((e = walker.next())) { + if (!e.entering) { + continue; + } + + const node = e.node; + + if (node.type !== 'text') { + continue; + } + + while (node._next && node._next.type === 'text') { + const next = node._next; + + node.literal += next.literal; + + node._next = next._next; + if (node._next) { + node._next._prev = node; + } + + if (node._parent._lastChild === next) { + node._parent._lastChild = node; + } + } + } + + return ast; +} + // Add indices to the items of every list export function addListItemIndices(ast) { const walker = ast.walker(); @@ -29,37 +66,29 @@ export function addListItemIndices(ast) { return ast; } -// Take all images, that aren't inside of tables, and move them to be children of the root document node. +// Take all images that aren't inside of tables and move them to be children of the root document node. // When this happens, their parent nodes are split into two, if necessary, with the version that follows // the image having its "continue" field set to true to indicate that things like bullet points don't // need to be rendered. export function pullOutImages(ast) { - for (let block = ast.firstChild; block !== null; block = block.next) { - // Skip tables because we'll render images inside of those as links - if (block.type === 'table') { + const walker = ast.walker(); + + let e; + while ((e = walker.next())) { + if (!e.entering) { continue; } - let node = block.firstChild; + const node = e.node; - let cameFromChild = false; + // Skip tables because we'll render images inside of those as links + if (node.type === 'table') { + walker.resumeAt(node, false); + continue; + } - while (node && node !== block) { - if (node.type === 'image' && node.parent.type !== 'document') { - pullOutImage(node); - } - - // Walk through tree to next node - if (node.firstChild && !cameFromChild) { - node = node.firstChild; - cameFromChild = false; - } else if (node.next) { - node = node.next; - cameFromChild = false; - } else { - node = node.parent; - cameFromChild = true; - } + if (node.type === 'image' && node.parent.type !== 'document') { + pullOutImage(node); } } @@ -219,3 +248,140 @@ function getLastSibling(node) { return sibling; } + +export function highlightMentions(ast, mentionKeys) { + const walker = ast.walker(); + + // console.warn(mentionKeys); + + let e; + while ((e = walker.next())) { + if (!e.entering) { + continue; + } + + const node = e.node; + + if (node.type === 'text') { + const {index, mention} = getFirstMention(node.literal, mentionKeys); + + if (index === -1 || !mention) { + continue; + } + + const mentionNode = highlightTextNode(node, index, index + mention.key.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 + walker.resumeAt(mentionNode, false); + } else if (node.type === 'at_mention') { + const matches = mentionKeys.some((mention) => { + const mentionName = '@' + node.mentionName; + + if (mention.caseSensitive) { + return mention.key === mentionName; + } + + return mention.key.toLowerCase() === mentionName.toLowerCase(); + }); + + if (!matches) { + continue; + } + + const wrapper = new Node('mention_highlight'); + wrapNode(wrapper, node); + + // Skip processing the wrapper to prevent checking this node again as its child + walker.resumeAt(wrapper, false); + } + } + + return ast; +} + +// Given a string and an array of mention keys, returns the first mention that appears and its index. +export function getFirstMention(str, mentionKeys) { + let firstMention = null; + let firstMentionIndex = -1; + + for (const mention of mentionKeys) { + const flags = mention.caseSensitive ? '' : 'i'; + const pattern = new RegExp(`\\b${escapeRegex(mention.key)}_*\\b`, flags); + + const match = pattern.exec(str); + if (!match) { + continue; + } + + if (firstMentionIndex === -1 || match.index < firstMentionIndex) { + firstMentionIndex = match.index; + firstMention = mention; + } + } + + return { + index: firstMentionIndex, + mention: firstMention, + }; +} + +// Given a text node, start/end indices, and a highlight node type, splits it into up to three nodes: +// the text before the highlight (if any exists), the highlighted text, and the text after the highlight +// the end of the highlight (if any exists). Returns a node containing the highlighted text. +export function highlightTextNode(node, start, end, type) { + const literal = node.literal; + node.literal = literal.substring(start, end); + + // Start by wrapping the node and then re-insert any non-highlighted code around it + const highlighted = new Node(type); + wrapNode(highlighted, node); + + if (start !== 0) { + const before = new Node('text'); + before.literal = literal.substring(0, start); + + highlighted.insertBefore(before); + } + + if (end !== literal.length) { + const after = new Node('text'); + after.literal = literal.substring(end); + + highlighted.insertAfter(after); + } + + return highlighted; +} + +// Wraps a given node in another node of the given type. The wrapper will take the place of +// the node in the AST relative to its parents and siblings, and it will have the node as +// its only child. +function wrapNode(wrapper, node) { + // Set parent and update parent's children if necessary + wrapper._parent = node._parent; + if (node._parent._firstChild === node) { + node._parent._firstChild = wrapper; + } + if (node._parent._lastChild === node) { + node._parent._lastChild = wrapper; + } + + // Set siblings and update those if necessary + wrapper._prev = node._prev; + node._prev = null; + if (wrapper._prev) { + wrapper._prev._next = wrapper; + } + + wrapper._next = node._next; + node._next = null; + if (wrapper._next) { + wrapper._next._prev = wrapper; + } + + // Make node a child of wrapper + wrapper._firstChild = node; + wrapper._lastChild = node; + node._parent = wrapper; +} diff --git a/app/components/markdown/transform.test.js b/app/components/markdown/transform.test.js index 397c07fb2..8eb1f7a6c 100644 --- a/app/components/markdown/transform.test.js +++ b/app/components/markdown/transform.test.js @@ -6,14 +6,186 @@ import {Node, Parser} from 'commonmark'; import { addListItemIndices, + combineTextNodes, + getFirstMention, + highlightMentions, + highlightTextNode, pullOutImages, } from 'app/components/markdown/transform'; -/* eslint-disable max-nested-callbacks, no-console */ +/* eslint-disable no-console, no-underscore-dangle */ describe('Components.Markdown.transform', () => { const parser = new Parser(); + describe('combineTextNodes', () => { + const tests = [{ + name: 'no text nodes', + input: { + type: 'document', + children: [{ + type: 'thematic_break', + }], + }, + expected: { + type: 'document', + children: [{ + type: 'thematic_break', + }], + }, + }, { + name: 'one text node', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + }, { + name: 'multiple text nodes', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'text', + literal: 'is a', + }, { + type: 'text', + literal: ' sen', + }, { + type: 'text', + literal: 'tence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + }, { + name: 'mixed formatting', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'is a', + }], + }, { + type: 'text', + literal: ' sen', + }, { + type: 'text', + literal: 'tence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'is a', + }], + }, { + type: 'text', + literal: ' sentence', + }], + }], + }, + }, { + name: 'multiple paragraphs', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a ', + }, { + type: 'text', + literal: 'paragraph', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is', + }, { + type: 'text', + literal: ' another ', + }, { + type: 'text', + literal: 'paragraph', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a paragraph', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is another paragraph', + }], + }], + }, + }]; + + for (const test of tests) { + it(test.name, () => { + const input = makeAst(test.input); + const expected = makeAst(test.expected); + const actual = combineTextNodes(input); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + } + }); + describe('addListItemIndices', () => { it('unordered list', () => { const input = makeAst({ @@ -2056,31 +2228,678 @@ describe('Components.Markdown.transform', () => { assert.deepStrictEqual(actual, expected); }); }); + + describe('highlightMentions', () => { + const tests = [{ + name: 'no mentions', + input: 'These are words', + mentionKeys: [], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are words', + }], + }], + }, + }, { + name: 'not an at-mention', + input: 'These are words', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }, + }, { + name: 'at-mention for another user', + input: 'This is @user', + mentionKeys: [{key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'at_mention', + _mentionName: 'user', + children: [{ + type: 'text', + literal: '@user', + }], + }], + }], + }, + }, { + name: 'at-mention', + input: 'These are @words', + mentionKeys: [{key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'at_mention', + _mentionName: 'words', + children: [{ + type: 'text', + literal: '@words', + }], + }], + }], + }], + }, + }, { + name: 'at-mention and non-at-mention for same word', + input: 'These are @words', + mentionKeys: [{key: 'words'}, {key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'at_mention', + _mentionName: 'words', + children: [{ + type: 'text', + literal: '@words', + }], + }], + }], + }], + }, + }, { + name: 'case insensitive mentions', + input: 'These are Words and wORDS', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' and ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'wORDS', + }], + }], + }], + }, + }, { + name: 'case sesitive mentions', + input: 'These are Words and wORDS', + mentionKeys: [{key: 'Words', caseSensitive: true}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' and wORDS', + }], + }], + }, + }, { + name: 'bold', + input: 'These are **words** in a sentence', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'strong', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }, { + type: 'text', + literal: ' in a sentence', + }], + }], + }, + }, { + name: 'italics', + input: 'These _are Words in_ a sentence', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' in', + }], + }, { + type: 'text', + literal: ' a sentence', + }], + }], + }, + }, { + name: 'code span', + input: 'These are `words`', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'code', + literal: 'words', + }], + }], + }, + }, { + name: 'code block', + input: '```\nThese are\nwords\n```', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'code_block', + literal: 'These are\nwords\n', + }], + }, + }, { + name: 'link text', + input: 'These are [words words](https://example.com)', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'link', + destination: 'https://example.com', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ' ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }, { + name: 'link url', + input: 'This is [a link](https://example.com/words)', + mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'link', + destination: 'https://example.com/words', + title: '', + children: [{ + type: 'text', + literal: 'a link', + }], + }], + }], + }, + }, { + name: 'autolinked url', + input: 'https://example.com/words', + mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'https://example.com/words', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'https', + }], + }, { + type: 'text', + literal: '://', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'example', + }], + }, { + type: 'text', + literal: '.', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'com', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }, { + name: 'words with punctuation', + input: 'words. (words) words/words/words words:words', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '. (', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ') ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ' ', + }, { + type: 'link', + destination: 'words:words', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ':', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }]; + + for (const test of tests) { + it(test.name, () => { + const input = combineTextNodes(parser.parse(test.input)); + const expected = makeAst(test.expected); + const actual = highlightMentions(input, test.mentionKeys); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected)); + }); + } + }); + + describe('getFirstMention', () => { + const tests = [{ + name: 'no mention keys', + input: 'apple banana orange', + mentionKeys: [], + expected: {index: -1, mention: null}, + }, { + name: 'single mention', + input: 'apple banana orange', + mentionKeys: [{key: 'banana'}], + expected: {index: 6, mention: {key: 'banana'}}, + }, { + name: 'multiple mentions', + input: 'apple banana orange', + mentionKeys: [{key: 'apple'}, {key: 'orange'}], + expected: {index: 0, mention: {key: 'apple'}}, + }, { + name: 'case sensitive', + input: 'apple APPLE Apple aPPle', + mentionKeys: [{key: 'Apple', caseSensitive: true}], + expected: {index: 12, mention: {key: 'Apple', caseSensitive: true}}, + }, { + name: 'followed by period', + input: 'banana.', + mentionKeys: [{key: 'banana'}], + expected: {index: 0, mention: {key: 'banana'}}, + }, { + name: 'followed by underscores', + input: 'banana__', + mentionKeys: [{key: 'banana'}], + expected: {index: 0, mention: {key: 'banana'}}, + }, { + name: 'in brackets', + input: '(banana)', + mentionKeys: [{key: 'banana'}], + expected: {index: 1, mention: {key: 'banana'}}, + }, { + name: 'following punctuation', + input: ':banana', + mentionKeys: [{key: 'banana'}], + expected: {index: 1, mention: {key: 'banana'}}, + }, { + name: 'not part of another word', + input: 'pineapple', + mentionKeys: [{key: 'apple'}], + expected: {index: -1, mention: null}, + }, { + name: 'no error from weird mention keys', + input: 'apple banana orange', + mentionKeys: [{key: '*\\3_.'}], + expected: {index: -1, mention: null}, + }]; + + for (const test of tests) { + it(test.name, () => { + const actual = getFirstMention(test.input, test.mentionKeys); + + assert.deepStrictEqual(actual, test.expected); + }); + } + }); + + describe('highlightTextNode', () => { + const type = 'my_highlight'; + const literal = 'This is a sentence'; + + const tests = [{ + name: 'highlight entire text', + start: 0, + end: literal.length, + expected: [{ + type, + children: [{ + type: 'text', + literal, + }], + }], + }, { + name: 'highlight start of text', + start: 0, + end: 6, + expected: [{ + type, + children: [{ + type: 'text', + literal: 'This i', + }], + }, { + type: 'text', + literal: 's a sentence', + }], + }, { + name: 'highlight end of text', + start: 8, + end: literal.length, + expected: [{ + type: 'text', + literal: 'This is ', + }, { + type, + children: [{ + type: 'text', + literal: 'a sentence', + }], + }], + }, { + name: 'highlight middle of text', + start: 5, + end: 12, + expected: [{ + type: 'text', + literal: 'This ', + }, { + type, + children: [{ + type: 'text', + literal: 'is a se', + }], + }, { + type: 'text', + literal: 'ntence', + }], + }]; + + for (const test of tests) { + it(test.name + ', without siblings', () => { + const actual = makeAst({ + type: 'parent', + children: [{ + type: 'text', + literal, + }], + }); + const expected = makeAst({ + type: 'parent', + children: test.expected, + }); + + const node = actual.firstChild; + assert.equal(node.type, 'text'); + + const highlighted = highlightTextNode(node, test.start, test.end, type); + assert.equal(highlighted.type, type); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + + it(test.name + ', with siblings', () => { + const actual = makeAst({ + type: 'parent', + children: [{ + type: 'previous', + }, { + type: 'text', + literal, + }, { + type: 'next', + }], + }); + const expected = makeAst({ + type: 'parent', + children: [ + { + type: 'previous', + }, + ...test.expected, + { + type: 'next', + }, + ], + }); + + const node = actual.firstChild.next; + assert.equal(node.type, 'text'); + + const highlighted = highlightTextNode(node, test.start, test.end, type); + assert.equal(highlighted.type, type); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + } + }); }); // Testing and debugging functions +// Confirms that all parent, child, and sibling linkages are correct and go both ways. function verifyAst(node) { if (node.prev && node.prev.next !== node) { console.error('node is not linked properly to prev'); + return false; } if (node.next && node.next.prev !== node) { console.error('node is not linked properly to prev'); + return false; + } + + if (!node.firstChild && node.lastChild) { + console.error('node has children, but is not linked to first child'); + return false; + } + + if (node.firstChild && !node.lastChild) { + console.error('node has children, but is not linked to last child'); + return false; } for (let child = node.firstChild; child; child = child.next) { if (child.parent !== node) { console.error('node is not linked properly to child'); + return false; + } + + if (!verifyAst(child)) { + return false; + } + + if (!child.next && child !== node.lastChild) { + console.error('node children are not linked correctly'); + return false; } } if (node.firstChild && node.firstChild.prev) { console.error('node\'s first child has previous sibling'); + return false; } if (node.lastChild && node.lastChild.next) { console.error('node\'s last child has next sibling'); + return false; } return true; @@ -2206,3 +3025,23 @@ function makeAst(input) { return node; } + +// Remove any fields from the AST that are only used while parsing to make testing equality easier. +function stripUnusedFields(node) { + const walker = node.walker(); + + let e; + while ((e = walker.next())) { + e.node._open = false; + e.node._size = null; + e.node._sourcepos = null; + + e.node._fenceChar = null; + e.node._fenceLength = 0; + e.node._fenceOffset = 0; + e.node._info = ''; + e.node._isFenced = false; + } + + return node; +} diff --git a/app/utils/markdown.js b/app/utils/markdown.js index 38d633e0b..4a784de30 100644 --- a/app/utils/markdown.js +++ b/app/utils/markdown.js @@ -89,6 +89,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => { table_header_row: { fontWeight: '700', }, + mention_highlight: { + backgroundColor: theme.mentionHighlightBg, + }, }; }); diff --git a/package-lock.json b/package-lock.json index 34d52c064..b6b5a1c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5176,8 +5176,8 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, "commonmark": { - "version": "github:mattermost/commonmark.js#95999d98559338f0249ffb3ab72984b48eccea42", - "from": "github:mattermost/commonmark.js#95999d9", + "version": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6", + "from": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6", "requires": { "entities": "~ 1.1.1", "mdurl": "~ 1.0.1", @@ -5187,8 +5187,8 @@ } }, "commonmark-react-renderer": { - "version": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870", - "from": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870", + "version": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42", + "from": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42", "requires": { "in-publish": "^2.0.0", "lodash.assign": "^4.2.0", diff --git a/package.json b/package.json index 484625373..6de63af6e 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "@babel/polyfill": "7.0.0", "@babel/runtime": "7.0.0", "analytics-react-native": "1.2.0", - "commonmark": "github:mattermost/commonmark.js#95999d9", - "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870", + "commonmark": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6", + "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42", "deep-equal": "1.0.1", "fuse.js": "3.2.1", "intl": "1.2.5",