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",