diff options
author | Harrison Healey <harrisonmhealey@gmail.com> | 2016-08-29 09:50:00 -0400 |
---|---|---|
committer | Christopher Speller <crspeller@gmail.com> | 2016-08-29 09:50:00 -0400 |
commit | 167dd22eefeeeb9c1eaebd990a4f5902bd366302 (patch) | |
tree | 6ddb15a80b2a608d42e20df72b98c0ae72821671 | |
parent | 55342e8fe16613f06528ed1aa726231e9b597d26 (diff) | |
download | chat-167dd22eefeeeb9c1eaebd990a4f5902bd366302.tar.gz chat-167dd22eefeeeb9c1eaebd990a4f5902bd366302.tar.bz2 chat-167dd22eefeeeb9c1eaebd990a4f5902bd366302.zip |
PLT-1752/PLT-3567/PLT-3998 Highlighting links in search, unit tests for autolinking (#3865)
* Added highlighting to links when their URL includes the search term
* Decoupling UserStore from react-router to allow for unit tests involving it
* PLT-3998 Added SiteURL as an option to be passed into the text formatting code
* Removed reference to PreferenceStore and window from TextFormatting
* Refactored TextFormatting to remove remaining browser-only code
* Updated ChannelHeader and MessageWrapper to match the changes to TextFormatting
* Increased max listeners for Preference and Emoji stores
* PLT-3832 Added automated unit tests for autolinking
* PLT-3567 Rerender posts when mention keywords change
* Updated RHS and search to match the changes to TextFormatting
* Broke TextFormatting's dependency on the UserStore
20 files changed, 765 insertions, 142 deletions
diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 65c856b4a..6cecc04bd 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -581,9 +581,9 @@ export default class ChannelHeader extends React.Component { ref='headerOverlay' > <div - onClick={TextFormatting.handleClick} + onClick={Utils.handleFormattedTextClick} className='description' - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: true, mentionHighlight: false})}} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: true, mentionHighlight: false, siteURL: Utils.getSiteURL()})}} /> </OverlayTrigger> </div> diff --git a/webapp/components/message_wrapper.jsx b/webapp/components/message_wrapper.jsx index 5e9939efa..4dba1024e 100644 --- a/webapp/components/message_wrapper.jsx +++ b/webapp/components/message_wrapper.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import * as TextFormatting from 'utils/text_formatting.jsx'; +import * as Utils from 'utils/utils.jsx'; import React from 'react'; @@ -10,9 +11,19 @@ export default class MessageWrapper extends React.Component { super(props); this.state = {}; } + render() { if (this.props.message) { - return <div dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.message, this.props.options)}}/>; + const options = Object.assign({}, this.props.options, { + siteURL: Utils.getSiteURL() + }); + + return ( + <div + onClick={Utils.handleFormattedTextClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.message, options)}} + /> + ); } return <div/>; diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx index 1b94f717d..e9019bf38 100644 --- a/webapp/components/post_view/components/post.jsx +++ b/webapp/components/post_view/components/post.jsx @@ -114,10 +114,6 @@ export default class Post extends React.Component { return true; } - if (nextProps.emojis !== this.props.emojis) { - return true; - } - if (nextState.dropdownOpened !== this.state.dropdownOpened) { return true; } @@ -259,7 +255,6 @@ export default class Post extends React.Component { handleCommentClick={this.handleCommentClick} compactDisplay={this.props.compactDisplay} previewCollapsed={this.props.previewCollapsed} - emojis={this.props.emojis} /> </div> </div> @@ -279,7 +274,6 @@ Post.propTypes = { isLastComment: React.PropTypes.bool, shouldHighlight: React.PropTypes.bool, displayNameType: React.PropTypes.string, - hasProfiles: React.PropTypes.bool, currentUser: React.PropTypes.object.isRequired, center: React.PropTypes.bool, compactDisplay: React.PropTypes.bool, @@ -287,7 +281,6 @@ Post.propTypes = { commentCount: React.PropTypes.number, isCommentMention: React.PropTypes.bool, useMilitaryTime: React.PropTypes.bool.isRequired, - emojis: React.PropTypes.object.isRequired, isFlagged: React.PropTypes.bool, status: React.PropTypes.string }; diff --git a/webapp/components/post_view/components/post_body.jsx b/webapp/components/post_view/components/post_body.jsx index 348e7fc93..8c3b3009f 100644 --- a/webapp/components/post_view/components/post_body.jsx +++ b/webapp/components/post_view/components/post_body.jsx @@ -6,8 +6,8 @@ import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import Constants from 'utils/constants.jsx'; -import * as TextFormatting from 'utils/text_formatting.jsx'; import PostBodyAdditionalContent from './post_body_additional_content.jsx'; +import PostMessageContainer from './post_message_container.jsx'; import PendingPostOptions from './pending_post_options.jsx'; import {FormattedMessage} from 'react-intl'; @@ -43,10 +43,6 @@ export default class PostBody extends React.Component { return true; } - if (nextProps.emojis !== this.props.emojis) { - return true; - } - return false; } @@ -165,10 +161,7 @@ export default class PostBody extends React.Component { ); } else { message = ( - <span - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, {emojis: this.props.emojis})}} - /> + <PostMessageContainer post={this.props.post}/> ); } @@ -215,6 +208,5 @@ PostBody.propTypes = { retryPost: React.PropTypes.func.isRequired, handleCommentClick: React.PropTypes.func.isRequired, compactDisplay: React.PropTypes.bool, - previewCollapsed: React.PropTypes.string, - emojis: React.PropTypes.object.isRequired + previewCollapsed: React.PropTypes.string }; diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx index fc532c373..f891afdb3 100644 --- a/webapp/components/post_view/components/post_list.jsx +++ b/webapp/components/post_view/components/post_list.jsx @@ -314,7 +314,6 @@ export default class PostList extends React.Component { compactDisplay={this.props.compactDisplay} previewCollapsed={this.props.previewsCollapsed} useMilitaryTime={this.props.useMilitaryTime} - emojis={this.props.emojis} isFlagged={isFlagged} status={status} /> @@ -584,7 +583,6 @@ PostList.propTypes = { previewsCollapsed: React.PropTypes.string, useMilitaryTime: React.PropTypes.bool.isRequired, isFocusPost: React.PropTypes.bool, - emojis: React.PropTypes.object.isRequired, flaggedPosts: React.PropTypes.object, statuses: React.PropTypes.object }; diff --git a/webapp/components/post_view/components/post_message_container.jsx b/webapp/components/post_view/components/post_message_container.jsx new file mode 100644 index 000000000..4ab556fca --- /dev/null +++ b/webapp/components/post_view/components/post_message_container.jsx @@ -0,0 +1,87 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import EmojiStore from 'stores/emoji_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; +import {Preferences} from 'utils/constants.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import PostMessageView from './post_message_view.jsx'; + +export default class PostMessageContainer extends React.Component { + static propTypes = { + post: React.PropTypes.object.isRequired, + options: React.PropTypes.object + }; + + static defaultProps = { + options: {} + }; + + constructor(props) { + super(props); + + this.onEmojiChange = this.onEmojiChange.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); + this.onUserChange = this.onUserChange.bind(this); + + const mentionKeys = UserStore.getCurrentMentionKeys(); + mentionKeys.push('@here'); + + this.state = { + emojis: EmojiStore.getEmojis(), + enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), + mentionKeys, + usernameMap: UserStore.getProfilesUsernameMap() + }; + } + + componentDidMount() { + EmojiStore.addChangeListener(this.onEmojiChange); + PreferenceStore.addChangeListener(this.onPreferenceChange); + UserStore.addChangeListener(this.onUserChange); + } + + componentWillUnmount() { + EmojiStore.removeChangeListener(this.onEmojiChange); + PreferenceStore.removeChangeListener(this.onPreferenceChange); + UserStore.removeChangeListener(this.onUserChange); + } + + onEmojiChange() { + this.setState({ + emojis: EmojiStore.getEmojis() + }); + } + + onPreferenceChange() { + this.setState({ + enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true) + }); + } + + onUserChange() { + const mentionKeys = UserStore.getCurrentMentionKeys(); + mentionKeys.push('@here'); + + this.setState({ + mentionKeys, + usernameMap: UserStore.getProfilesUsernameMap() + }); + } + + render() { + return ( + <PostMessageView + options={this.props.options} + message={this.props.post.message} + emojis={this.state.emojis} + enableFormatting={this.state.enableFormatting} + mentionKeys={this.state.mentionKeys} + usernameMap={this.state.usernameMap} + /> + ); + } +}
\ No newline at end of file diff --git a/webapp/components/post_view/components/post_message_view.jsx b/webapp/components/post_view/components/post_message_view.jsx new file mode 100644 index 000000000..99589c973 --- /dev/null +++ b/webapp/components/post_view/components/post_message_view.jsx @@ -0,0 +1,66 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as TextFormatting from 'utils/text_formatting.jsx'; +import * as Utils from 'utils/utils.jsx'; + +export default class PostMessageView extends React.Component { + static propTypes = { + options: React.PropTypes.object.isRequired, + message: React.PropTypes.string.isRequired, + emojis: React.PropTypes.object.isRequired, + enableFormatting: React.PropTypes.bool.isRequired, + mentionKeys: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + usernameMap: React.PropTypes.object.isRequired + }; + + shouldComponentUpdate(nextProps) { + if (!Utils.areObjectsEqual(nextProps.options, this.props.options)) { + return true; + } + + if (nextProps.message !== this.props.message) { + return true; + } + + // emojis are immutable + if (nextProps.emojis !== this.props.emojis) { + return true; + } + + if (nextProps.enableFormatting !== this.props.enableFormatting) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.mentionKeys, this.props.mentionKeys)) { + return true; + } + + // Don't check if props.usernameMap changes since it is very large and inefficient to do so. + // This mimics previous behaviour, but could be changed if we decide it's worth it. + + return false; + } + + render() { + if (!this.props.enableFormatting) { + return <span>{this.props.message}</span>; + } + + const options = Object.assign({}, this.props.options, { + emojis: this.props.emojis, + siteURL: Utils.getSiteURL(), + mentionKeys: this.props.mentionKeys, + usernameMap: this.props.usernameMap + }); + + return ( + <span + onClick={Utils.handleFormattedTextClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.message, options)}} + /> + ); + } +}
\ No newline at end of file diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx index 7e30818fb..58f8eff74 100644 --- a/webapp/components/post_view/post_view_controller.jsx +++ b/webapp/components/post_view/post_view_controller.jsx @@ -4,7 +4,6 @@ import PostList from './components/post_list.jsx'; import LoadingScreen from 'components/loading_screen.jsx'; -import EmojiStore from 'stores/emoji_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PostStore from 'stores/post_store.jsx'; @@ -25,7 +24,6 @@ export default class PostViewController extends React.Component { this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onUserChange = this.onUserChange.bind(this); this.onPostsChange = this.onPostsChange.bind(this); - this.onEmojisChange = this.onEmojisChange.bind(this); this.onStatusChange = this.onStatusChange.bind(this); this.onPostsViewJumpRequest = this.onPostsViewJumpRequest.bind(this); this.onSetNewMessageIndicator = this.onSetNewMessageIndicator.bind(this); @@ -67,7 +65,6 @@ export default class PostViewController extends React.Component { compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, previewsCollapsed: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, 'false'), useMilitaryTime: PreferenceStore.getBool(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), - emojis: EmojiStore.getEmojis(), flaggedPosts: PreferenceStore.getCategory(Constants.Preferences.CATEGORY_FLAGGED_POST) }; } @@ -123,12 +120,6 @@ export default class PostViewController extends React.Component { }); } - onEmojisChange() { - this.setState({ - emojis: EmojiStore.getEmojis() - }); - } - onStatusChange() { const channel = this.state.channel; let statuses; @@ -145,7 +136,6 @@ export default class PostViewController extends React.Component { UserStore.addStatusesChangeListener(this.onStatusChange); PostStore.addChangeListener(this.onPostsChange); PostStore.addPostsViewJumpListener(this.onPostsViewJumpRequest); - EmojiStore.addChangeListener(this.onEmojisChange); ChannelStore.addLastViewedListener(this.onSetNewMessageIndicator); } @@ -155,7 +145,6 @@ export default class PostViewController extends React.Component { UserStore.removeStatusesChangeListener(this.onStatusChange); PostStore.removeChangeListener(this.onPostsChange); PostStore.removePostsViewJumpListener(this.onPostsViewJumpRequest); - EmojiStore.removeChangeListener(this.onEmojisChange); ChannelStore.removeLastViewedListener(this.onSetNewMessageIndicator); } @@ -298,10 +287,6 @@ export default class PostViewController extends React.Component { return true; } - if (nextState.emojis !== this.state.emojis) { - return true; - } - return false; } @@ -332,7 +317,6 @@ export default class PostViewController extends React.Component { useMilitaryTime={this.state.useMilitaryTime} flaggedPosts={this.state.flaggedPosts} lastViewed={this.state.lastViewed} - emojis={this.state.emojis} ownNewMessage={this.state.ownNewMessage} statuses={this.state.statuses} /> diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx index 05df1ac5f..c9588eb33 100644 --- a/webapp/components/rhs_comment.jsx +++ b/webapp/components/rhs_comment.jsx @@ -4,6 +4,7 @@ import UserProfile from './user_profile.jsx'; import FileAttachmentList from './file_attachment_list.jsx'; import PendingPostOptions from 'components/post_view/components/pending_post_options.jsx'; +import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; import ProfilePicture from 'components/profile_picture.jsx'; import TeamStore from 'stores/team_store.jsx'; @@ -12,7 +13,6 @@ import UserStore from 'stores/user_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {flagPost, unflagPost} from 'actions/post_actions.jsx'; -import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; @@ -234,13 +234,7 @@ export default class RhsComment extends React.Component { } let loading; let postClass = ''; - let message = ( - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> - ); + let message = <PostMessageContainer post={post}/>; if (post.state === Constants.POST_FAILED) { postClass += ' post-fail'; diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index cbb000922..ea0c71cc7 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -3,6 +3,7 @@ import UserProfile from './user_profile.jsx'; import PostBodyAdditionalContent from 'components/post_view/components/post_body_additional_content.jsx'; +import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; import FileAttachmentList from './file_attachment_list.jsx'; import ProfilePicture from 'components/profile_picture.jsx'; @@ -15,7 +16,6 @@ import {flagPost, unflagPost} from 'actions/post_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; -import * as TextFormatting from 'utils/text_formatting.jsx'; import Constants from 'utils/constants.jsx'; import {Tooltip, OverlayTrigger} from 'react-bootstrap'; @@ -305,13 +305,7 @@ export default class RhsRootPost extends React.Component { profilePicContainer = ''; } - const messageWrapper = ( - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> - ); + const messageWrapper = <PostMessageContainer post={post}/>; let flag; let flagFunc; diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index ada5e0ea6..2260f99ad 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import $ from 'jquery'; +import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; import UserProfile from './user_profile.jsx'; import TeamStore from 'stores/team_store.jsx'; @@ -11,7 +12,6 @@ import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {flagPost, unflagPost} from 'actions/post_actions.jsx'; -import * as TextFormatting from 'utils/text_formatting.jsx'; import * as Utils from 'utils/utils.jsx'; import * as PostUtils from 'utils/post_utils.jsx'; @@ -78,11 +78,6 @@ export default class SearchResultsItem extends React.Component { } } - const formattingOptions = { - searchTerm: this.props.term, - mentionHighlight: this.props.isMentionSearch - }; - let overrideUsername; let disableProfilePopover = false; if (post.props && @@ -251,9 +246,12 @@ export default class SearchResultsItem extends React.Component { </li> </ul> <div className='search-item-snippet'> - <span - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message, formattingOptions)}} + <PostMessageContainer + post={post} + options={{ + searchTerm: this.props.term, + mentionHighlight: this.props.isMentionSearch + }} /> </div> </div> diff --git a/webapp/root.jsx b/webapp/root.jsx index d5b9a6d47..0fef23a53 100644 --- a/webapp/root.jsx +++ b/webapp/root.jsx @@ -16,6 +16,7 @@ import * as I18n from 'i18n/i18n.jsx'; import 'bootstrap-colorpicker/dist/css/bootstrap-colorpicker.css'; import 'google-fonts/google-fonts.css'; import 'sass/styles.scss'; +import 'katex/dist/katex.min.css'; // Import the root of our routing tree import rRoot from 'routes/route_root.jsx'; diff --git a/webapp/stores/emoji_store.jsx b/webapp/stores/emoji_store.jsx index e369885b4..076e671dd 100644 --- a/webapp/stores/emoji_store.jsx +++ b/webapp/stores/emoji_store.jsx @@ -17,6 +17,8 @@ class EmojiStore extends EventEmitter { this.dispatchToken = AppDispatcher.register(this.handleEventPayload.bind(this)); + this.setMaxListeners(600); + this.emojis = new Map(EmojiJson); this.systemEmojis = new Map(EmojiJson); diff --git a/webapp/stores/preference_store.jsx b/webapp/stores/preference_store.jsx index 654036ae8..8aecfff40 100644 --- a/webapp/stores/preference_store.jsx +++ b/webapp/stores/preference_store.jsx @@ -8,7 +8,7 @@ import EventEmitter from 'events'; const CHANGE_EVENT = 'change'; -class PreferenceStoreClass extends EventEmitter { +class PreferenceStore extends EventEmitter { constructor() { super(); @@ -17,7 +17,7 @@ class PreferenceStoreClass extends EventEmitter { this.preferences = new Map(); - this.setMaxListeners(20); + this.setMaxListeners(600); } getKey(category, name) { @@ -144,7 +144,5 @@ class PreferenceStoreClass extends EventEmitter { } } -const PreferenceStore = new PreferenceStoreClass(); -PreferenceStore.setMaxListeners(25); -export default PreferenceStore; -global.window.PreferenceStore = PreferenceStore; +global.PreferenceStore = new PreferenceStore(); +export default global.PreferenceStore;
\ No newline at end of file diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx index 6a47d0f65..859f385c0 100644 --- a/webapp/stores/user_store.jsx +++ b/webapp/stores/user_store.jsx @@ -334,7 +334,7 @@ class UserStoreClass extends EventEmitter { } var UserStore = new UserStoreClass(); -UserStore.setMaxListeners(16); +UserStore.setMaxListeners(600); UserStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; diff --git a/webapp/tests/formatting_links.test.jsx b/webapp/tests/formatting_links.test.jsx new file mode 100644 index 000000000..237ef6121 --- /dev/null +++ b/webapp/tests/formatting_links.test.jsx @@ -0,0 +1,504 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Markdown from 'utils/markdown.jsx'; +import * as TextFormatting from 'utils/text_formatting.jsx'; + +describe('Markdown.Links', function() { + this.timeout(10000); + + it('Not links', function(done) { + assert.equal( + Markdown.format('example.com').trim(), + '<p>example.com</p>' + ); + + assert.equal( + Markdown.format('readme.md').trim(), + '<p>readme.md</p>' + ); + + assert.equal( + Markdown.format('@example.com').trim(), + '<p>@example.com</p>' + ); + + assert.equal( + Markdown.format('./make-compiled-client.sh').trim(), + '<p>./make-compiled-client.sh</p>' + ); + + assert.equal( + Markdown.format('test.:test').trim(), + '<p>test.:test</p>' + ); + + assert.equal( + Markdown.format('`https://example.com`').trim(), + '<p>' + + '<span class="codespan__pre-wrap">' + + '<code>' + + 'https://example.com' + + '</code>' + + '</span>' + + '</p>' + ); + + assert.equal( + Markdown.format('[link](example.com').trim(), + '<p>[link](example.com</p>' + ); + + done(); + }); + + it('External links', function(done) { + assert.equal( + Markdown.format('http://example.com').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a></p>' + ); + + assert.equal( + Markdown.format('https://example.com').trim(), + '<p><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com" rel="noreferrer" target="_blank">www.example.com</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/index').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index" rel="noreferrer" target="_blank">www.example.com/index</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/index.html').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index.html" rel="noreferrer" target="_blank">www.example.com/index.html</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/index/sub').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index/sub" rel="noreferrer" target="_blank">www.example.com/index/sub</a></p>' + ); + + assert.equal( + Markdown.format('www1.example.com').trim(), + '<p><a class="theme markdown__link" href="http://www1.example.com" rel="noreferrer" target="_blank">www1.example.com</a></p>' + ); + + assert.equal( + Markdown.format('example.com/index').trim(), + '<p><a class="theme markdown__link" href="http://example.com/index" rel="noreferrer" target="_blank">example.com/index</a></p>' + ); + + done(); + }); + + it('IP addresses', function(done) { + assert.equal( + Markdown.format('http://127.0.0.1').trim(), + '<p><a class="theme markdown__link" href="http://127.0.0.1" rel="noreferrer" target="_blank">http://127.0.0.1</a></p>' + ); + + assert.equal( + Markdown.format('http://192.168.1.1:4040').trim(), + '<p><a class="theme markdown__link" href="http://192.168.1.1:4040" rel="noreferrer" target="_blank">http://192.168.1.1:4040</a></p>' + ); + + assert.equal( + Markdown.format('http://[::1]:80').trim(), + '<p><a class="theme markdown__link" href="http://[::1]:80" rel="noreferrer" target="_blank">http://[::1]:80</a></p>' + ); + + assert.equal( + Markdown.format('http://[::1]:8065').trim(), + '<p><a class="theme markdown__link" href="http://[::1]:8065" rel="noreferrer" target="_blank">http://[::1]:8065</a></p>' + ); + + assert.equal( + Markdown.format('https://[::1]:80').trim(), + '<p><a class="theme markdown__link" href="https://[::1]:80" rel="noreferrer" target="_blank">https://[::1]:80</a></p>' + ); + + assert.equal( + Markdown.format('http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80').trim(), + '<p><a class="theme markdown__link" href="http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80" rel="noreferrer" target="_blank">http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80</a></p>' + ); + + assert.equal( + Markdown.format('http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:8065').trim(), + '<p><a class="theme markdown__link" href="http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:8065" rel="noreferrer" target="_blank">http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:8065</a></p>' + ); + + assert.equal( + Markdown.format('https://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:443').trim(), + '<p><a class="theme markdown__link" href="https://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:443" rel="noreferrer" target="_blank">https://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:443</a></p>' + ); + + assert.equal( + Markdown.format('http://username:password@127.0.0.1').trim(), + '<p><a class="theme markdown__link" href="http://username:password@127.0.0.1" rel="noreferrer" target="_blank">http://username:password@127.0.0.1</a></p>' + ); + + assert.equal( + Markdown.format('http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80').trim(), + '<p><a class="theme markdown__link" href="http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80" rel="noreferrer" target="_blank">http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80</a></p>' + ); + + done(); + }); + + it('Links with anchors', function(done) { + assert.equal( + Markdown.format('https://en.wikipedia.org/wiki/URLs#Syntax').trim(), + '<p><a class="theme markdown__link" href="https://en.wikipedia.org/wiki/URLs#Syntax" rel="noreferrer" target="_blank">https://en.wikipedia.org/wiki/URLs#Syntax</a></p>' + ); + + assert.equal( + Markdown.format('https://groups.google.com/forum/#!msg').trim(), + '<p><a class="theme markdown__link" href="https://groups.google.com/forum/#!msg" rel="noreferrer" target="_blank">https://groups.google.com/forum/#!msg</a></p>' + ); + + done(); + }); + + it('Links with parameters', function(done) { + assert.equal( + Markdown.format('www.example.com/index?params=1').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index?params=1" rel="noreferrer" target="_blank">www.example.com/index?params=1</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/index?params=1&other=2').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index?params=1&other=2" rel="noreferrer" target="_blank">www.example.com/index?params=1&other=2</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/index?params=1;other=2').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/index?params=1;other=2" rel="noreferrer" target="_blank">www.example.com/index?params=1;other=2</a></p>' + ); + + assert.equal( + Markdown.format('http://example.com:8065').trim(), + '<p><a class="theme markdown__link" href="http://example.com:8065" rel="noreferrer" target="_blank">http://example.com:8065</a></p>' + ); + + assert.equal( + Markdown.format('http://username:password@example.com').trim(), + '<p><a class="theme markdown__link" href="http://username:password@example.com" rel="noreferrer" target="_blank">http://username:password@example.com</a></p>' + ); + + done(); + }); + + it('Special characters', function(done) { + assert.equal( + Markdown.format('http://www.example.com/_/page').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/_/page" rel="noreferrer" target="_blank">http://www.example.com/_/page</a></p>' + ); + + assert.equal( + Markdown.format('www.example.com/_/page').trim(), + '<p><a class="theme markdown__link" href="http://www.example.com/_/page" rel="noreferrer" target="_blank">www.example.com/_/page</a></p>' + ); + + assert.equal( + Markdown.format('https://en.wikipedia.org/wiki/🐬').trim(), + '<p><a class="theme markdown__link" href="https://en.wikipedia.org/wiki/🐬" rel="noreferrer" target="_blank">https://en.wikipedia.org/wiki/🐬</a></p>' + ); + + assert.equal( + Markdown.format('http://✪df.ws/1234').trim(), + '<p><a class="theme markdown__link" href="http://✪df.ws/1234" rel="noreferrer" target="_blank">http://✪df.ws/1234</a></p>' + ); + + done(); + }); + + it('Brackets', function(done) { + assert.equal( + Markdown.format('https://en.wikipedia.org/wiki/Rendering_(computer_graphics)').trim(), + '<p><a class="theme markdown__link" href="https://en.wikipedia.org/wiki/Rendering_(computer_graphics)" rel="noreferrer" target="_blank">https://en.wikipedia.org/wiki/Rendering_(computer_graphics)</a></p>' + ); + + assert.equal( + Markdown.format('http://example.com/more_(than)_one_(parens)').trim(), + '<p><a class="theme markdown__link" href="http://example.com/more_(than)_one_(parens)" rel="noreferrer" target="_blank">http://example.com/more_(than)_one_(parens)</a></p>' + ); + + assert.equal( + Markdown.format('http://example.com/(something)?after=parens').trim(), + '<p><a class="theme markdown__link" href="http://example.com/(something)?after=parens" rel="noreferrer" target="_blank">http://example.com/(something)?after=parens</a></p>' + ); + + assert.equal( + Markdown.format('http://foo.com/unicode_(✪)_in_parens').trim(), + '<p><a class="theme markdown__link" href="http://foo.com/unicode_(✪)_in_parens" rel="noreferrer" target="_blank">http://foo.com/unicode_(✪)_in_parens</a></p>' + ); + + done(); + }); + + it('Email addresses', function(done) { + assert.equal( + Markdown.format('test@example.com').trim(), + '<p><a class="theme" href="mailto:test@example.com">test@example.com</a></p>' + ); + assert.equal( + Markdown.format('test_underscore@example.com').trim(), + '<p><a class="theme" href="mailto:test_underscore@example.com">test_underscore@example.com</a></p>' + ); + + assert.equal( + Markdown.format('mailto:test@example.com').trim(), + '<p><a class="theme markdown__link" href="mailto:test@example.com" rel="noreferrer" target="_blank">mailto:test@example.com</a></p>' + ); + + done(); + }); + + it('Formatted links', function(done) { + assert.equal( + Markdown.format('*https://example.com*').trim(), + '<p><em><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></em></p>' + ); + + assert.equal( + Markdown.format('_https://example.com_').trim(), + '<p><em><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></em></p>' + ); + + assert.equal( + Markdown.format('**https://example.com**').trim(), + '<p><strong><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></strong></p>' + ); + + assert.equal( + Markdown.format('__https://example.com__').trim(), + '<p><strong><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></strong></p>' + ); + + assert.equal( + Markdown.format('***https://example.com***').trim(), + '<p><strong><em><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></em></strong></p>' + ); + + assert.equal( + Markdown.format('___https://example.com___').trim(), + '<p><strong><em><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></em></strong></p>' + ); + + assert.equal( + Markdown.format('<https://example.com>').trim(), + '<p><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">https://example.com</a></p>' + ); + + assert.equal( + Markdown.format('<https://en.wikipedia.org/wiki/Rendering_(computer_graphics)>').trim(), + '<p><a class="theme markdown__link" href="https://en.wikipedia.org/wiki/Rendering_(computer_graphics)" rel="noreferrer" target="_blank">https://en.wikipedia.org/wiki/Rendering_(computer_graphics)</a></p>' + ); + + done(); + }); + + it('Links with text', function(done) { + assert.equal( + Markdown.format('[example link](example.com)').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">example link</a></p>' + ); + + assert.equal( + Markdown.format('[example.com](example.com)').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">example.com</a></p>' + ); + + assert.equal( + Markdown.format('[example.com/other](example.com)').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">example.com/other</a></p>' + ); + + assert.equal( + Markdown.format('[example.com/other_link](example.com/example)').trim(), + '<p><a class="theme markdown__link" href="http://example.com/example" rel="noreferrer" target="_blank">example.com/other_link</a></p>' + ); + + assert.equal( + Markdown.format('[link with spaces](example.com/ spaces in the url)').trim(), + '<p><a class="theme markdown__link" href="http://example.com/ spaces in the url" rel="noreferrer" target="_blank">link with spaces</a></p>' + ); + + assert.equal( + Markdown.format('[This whole #sentence should be a link](https://example.com)').trim(), + '<p><a class="theme markdown__link" href="https://example.com" rel="noreferrer" target="_blank">This whole #sentence should be a link</a></p>' + ); + + assert.equal( + Markdown.format('[email link](mailto:test@example.com)').trim(), + '<p><a class="theme markdown__link" href="mailto:test@example.com" rel="noreferrer" target="_blank">email link</a></p>' + ); + + assert.equal( + Markdown.format('[other link](ts3server://example.com)').trim(), + '<p><a class="theme markdown__link" href="ts3server://example.com" rel="noreferrer" target="_blank">other link</a></p>' + ); + + done(); + }); + + it('Links with tooltips', function(done) { + assert.equal( + Markdown.format('[link](example.com "catch phrase!")').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank" title="catch phrase!">link</a></p>' + ); + + assert.equal( + Markdown.format('[link](example.com "title with "quotes"")').trim(), + '<p><a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank" title="title with "quotes"">link</a></p>' + ); + assert.equal( + Markdown.format('[link with spaces](example.com/ spaces in the url "and a title")').trim(), + '<p><a class="theme markdown__link" href="http://example.com/ spaces in the url" rel="noreferrer" target="_blank" title="and a title">link with spaces</a></p>' + ); + + done(); + }); + + it('Links with surrounding text', function(done) { + assert.equal( + Markdown.format('This is a sentence with a http://example.com in it.').trim(), + '<p>This is a sentence with a <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a> in it.</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a http://example.com/_/underscore in it.').trim(), + '<p>This is a sentence with a <a class="theme markdown__link" href="http://example.com/_/underscore" rel="noreferrer" target="_blank">http://example.com/_/underscore</a> in it.</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a http://192.168.1.1:4040 in it.').trim(), + '<p>This is a sentence with a <a class="theme markdown__link" href="http://192.168.1.1:4040" rel="noreferrer" target="_blank">http://192.168.1.1:4040</a> in it.</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a https://[::1]:80 in it.').trim(), + '<p>This is a sentence with a <a class="theme markdown__link" href="https://[::1]:80" rel="noreferrer" target="_blank">https://[::1]:80</a> in it.</p>' + ); + + done(); + }); + + it('Links with trailing punctuation', function(done) { + assert.equal( + Markdown.format('This is a link to http://example.com.').trim(), + '<p>This is a link to <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a>.</p>' + ); + + assert.equal( + Markdown.format('This is a link containing http://example.com/something?with,commas,in,url, but not at the end').trim(), + '<p>This is a link containing <a class="theme markdown__link" href="http://example.com/something?with,commas,in,url" rel="noreferrer" target="_blank">http://example.com/something?with,commas,in,url</a>, but not at the end</p>' + ); + + assert.equal( + Markdown.format('This is a question about a link http://example.com?').trim(), + '<p>This is a question about a link <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a>?</p>' + ); + + done(); + }); + + it('Links with surrounding brackets', function(done) { + assert.equal( + Markdown.format('(http://example.com)').trim(), + '<p>(<a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(see http://example.com)').trim(), + '<p>(see <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(http://example.com watch this)').trim(), + '<p>(<a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a> watch this)</p>' + ); + + assert.equal( + Markdown.format('(www.example.com)').trim(), + '<p>(<a class="theme markdown__link" href="http://www.example.com" rel="noreferrer" target="_blank">www.example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(see www.example.com)').trim(), + '<p>(see <a class="theme markdown__link" href="http://www.example.com" rel="noreferrer" target="_blank">www.example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(www.example.com watch this)').trim(), + '<p>(<a class="theme markdown__link" href="http://www.example.com" rel="noreferrer" target="_blank">www.example.com</a> watch this)</p>' + ); + assert.equal( + Markdown.format('([link](http://example.com))').trim(), + '<p>(<a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">link</a>)</p>' + ); + + assert.equal( + Markdown.format('(see [link](http://example.com))').trim(), + '<p>(see <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">link</a>)</p>' + ); + + assert.equal( + Markdown.format('([link](http://example.com) watch this)').trim(), + '<p>(<a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">link</a> watch this)</p>' + ); + + assert.equal( + Markdown.format('(test@example.com)').trim(), + '<p>(<a class="theme" href="mailto:test@example.com">test@example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(email test@example.com)').trim(), + '<p>(email <a class="theme" href="mailto:test@example.com">test@example.com</a>)</p>' + ); + + assert.equal( + Markdown.format('(test@example.com email)').trim(), + '<p>(<a class="theme" href="mailto:test@example.com">test@example.com</a> email)</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a [link](http://example.com) in it.').trim(), + '<p>This is a sentence with a <a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">link</a> in it.</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a link (http://example.com) in it.').trim(), + '<p>This is a sentence with a link (<a class="theme markdown__link" href="http://example.com" rel="noreferrer" target="_blank">http://example.com</a>) in it.</p>' + ); + + assert.equal( + Markdown.format('This is a sentence with a (https://en.wikipedia.org/wiki/Rendering_(computer_graphics)) in it.').trim(), + '<p>This is a sentence with a (<a class="theme markdown__link" href="https://en.wikipedia.org/wiki/Rendering_(computer_graphics)" rel="noreferrer" target="_blank">https://en.wikipedia.org/wiki/Rendering_(computer_graphics)</a>) in it.</p>' + ); + + done(); + }); + + it('Searching for links', function(done) { + assert.equal( + TextFormatting.formatText('https://en.wikipedia.org/wiki/Unix', {searchTerm: 'wikipedia'}).trim(), + '<p><a class="theme markdown__link search-highlight" href="https://en.wikipedia.org/wiki/Unix" rel="noreferrer" target="_blank">https:/<wbr />/<wbr />en.wikipedia.org/<wbr />wiki/<wbr />Unix</a></p>' + ); + + assert.equal( + TextFormatting.formatText('[Link](https://en.wikipedia.org/wiki/Unix)', {searchTerm: 'unix'}).trim(), + '<p><a class="theme markdown__link search-highlight" href="https://en.wikipedia.org/wiki/Unix" rel="noreferrer" target="_blank">Link</a></p>' + ); + + done(); + }); +}); diff --git a/webapp/utils/markdown.jsx b/webapp/utils/markdown.jsx index 028e667fd..f6b218812 100644 --- a/webapp/utils/markdown.jsx +++ b/webapp/utils/markdown.jsx @@ -6,12 +6,11 @@ import * as SyntaxHighlighting from './syntax_hightlighting.jsx'; import marked from 'marked'; import katex from 'katex'; -import 'katex/dist/katex.min.css'; function markdownImageLoaded(image) { image.style.height = 'auto'; } -window.markdownImageLoaded = markdownImageLoaded; +global.markdownImageLoaded = markdownImageLoaded; class MattermostMarkdownRenderer extends marked.Renderer { constructor(options, formattingOptions = {}) { @@ -63,11 +62,11 @@ class MattermostMarkdownRenderer extends marked.Renderer { const content = SyntaxHighlighting.highlight(usedLanguage, code); let searchedContent = ''; - if (this.formattingOptions.searchTerm) { + if (this.formattingOptions.searchPatterns) { const tokens = new Map(); let searched = TextFormatting.sanitizeHtml(code); - searched = TextFormatting.highlightSearchTerms(searched, tokens, this.formattingOptions.searchTerm); + searched = TextFormatting.highlightSearchTerms(searched, tokens, this.formattingOptions.searchPatterns); if (tokens.size > 0) { searched = TextFormatting.replaceTokens(searched, tokens); @@ -94,9 +93,9 @@ class MattermostMarkdownRenderer extends marked.Renderer { codespan(text) { let output = text; - if (this.formattingOptions.searchTerm) { + if (this.formattingOptions.searchPatterns) { const tokens = new Map(); - output = TextFormatting.highlightSearchTerms(output, tokens, this.formattingOptions.searchTerm); + output = TextFormatting.highlightSearchTerms(output, tokens, this.formattingOptions.searchPatterns); output = TextFormatting.replaceTokens(output, tokens); } @@ -149,11 +148,22 @@ class MattermostMarkdownRenderer extends marked.Renderer { outHref = `http://${outHref}`; } - let output = '<a class="theme markdown__link" href="' + outHref + '" rel="noreferrer"'; + let output = '<a class="theme markdown__link'; + + if (this.formattingOptions.searchPatterns) { + for (const pattern of this.formattingOptions.searchPatterns) { + if (pattern.test(href)) { + output += ' search-highlight'; + break; + } + } + } + + output += '" href="' + outHref + '" rel="noreferrer"'; // special case for channel links and permalinks that are inside the app - if (new RegExp('^' + TextFormatting.escapeRegex(global.mm_config.SiteURL) + '\\/[^\\/]+\\/(pl|channels)\\/').test(outHref)) { - output += ' data-link="' + outHref.substring(global.mm_config.SiteURL.length) + '"'; + if (this.formattingOptions.siteURL && new RegExp('^' + TextFormatting.escapeRegex(this.formattingOptions.siteURL) + '\\/[^\\/]+\\/(pl|channels)\\/').test(outHref)) { + output += ' data-link="' + outHref.substring(this.formattingOptions.siteURL) + '"'; } else { output += ' target="_blank"'; } @@ -201,7 +211,7 @@ class MattermostMarkdownRenderer extends marked.Renderer { } } -export function format(text, options) { +export function format(text, options = {}) { const markdownOptions = { renderer: new MattermostMarkdownRenderer(null, options), sanitize: true, diff --git a/webapp/utils/syntax_hightlighting.jsx b/webapp/utils/syntax_hightlighting.jsx index ce904c41f..4146c43c5 100644 --- a/webapp/utils/syntax_hightlighting.jsx +++ b/webapp/utils/syntax_hightlighting.jsx @@ -1,7 +1,6 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import * as Utils from './utils.jsx'; import * as TextFormatting from './text_formatting.jsx'; import Constants from './constants.jsx'; @@ -138,18 +137,17 @@ export function highlight(lang, code) { } export function getLanguageFromFilename(filename) { - const fileInfo = Utils.splitFileLocation(filename); - var ext = fileInfo.ext; - if (!ext) { - return null; - } + const fileSplit = filename.split('.'); + let ext = fileSplit.length > 1 ? fileSplit[fileSplit.length - 1] : ''; ext = ext.toLowerCase(); + for (var key in HighlightedLanguages) { if (HighlightedLanguages[key].extensions.find((x) => x === ext)) { return key; } } + return null; } diff --git a/webapp/utils/text_formatting.jsx b/webapp/utils/text_formatting.jsx index c110fc52f..f97c74625 100644 --- a/webapp/utils/text_formatting.jsx +++ b/webapp/utils/text_formatting.jsx @@ -2,15 +2,11 @@ // See License.txt for license information. import Autolinker from 'autolinker'; -import {browserHistory} from 'react-router/es6'; import Constants from './constants.jsx'; import EmojiStore from 'stores/emoji_store.jsx'; import * as Emoticons from './emoticons.jsx'; import * as Markdown from './markdown.jsx'; -import PreferenceStore from 'stores/preference_store.jsx'; -import UserStore from 'stores/user_store.jsx'; import twemoji from 'twemoji'; -import * as Utils from './utils.jsx'; import XRegExp from 'xregexp'; // pattern to detect the existance of a Chinese, Japanese, or Korean character in a string @@ -22,16 +18,19 @@ const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00- // as part of the second parameter: // - searchTerm - If specified, this word is highlighted in the resulting html. Defaults to nothing. // - mentionHighlight - Specifies whether or not to highlight mentions of the current user. Defaults to true. +// - mentionKeys - A list of mention keys for the current user to highlight. // - singleline - Specifies whether or not to remove newlines. Defaults to false. // - emoticons - Enables emoticon parsing. Defaults to true. // - markdown - Enables markdown parsing. Defaults to true. -export function formatText(text, options = {}) { +// - siteURL - The origin of this Mattermost instance. If provided, links to channels and posts will be replaced with internal +// links that can be handled by a special click handler. +// - usernameMap - An object mapping usernames to users. If provided, at mentions will be replaced with internal links that can +// be handled by a special click handler (Utils.handleFormattedTextClick) +export function formatText(text, inputOptions) { let output = text; - // would probably make more sense if it was on the calling components, but this option is intended primarily for debugging - if (window.mm_config.EnableDeveloper === 'true' && PreferenceStore.get(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', 'true') === 'false') { - return output; - } + const options = Object.assign({}, inputOptions); + options.searchPatterns = parseSearchTerms(options.searchTerm).map(convertSearchTermToRegex); if (!('markdown' in options) || options.markdown) { // the markdown renderer will call doFormatText as necessary @@ -58,7 +57,10 @@ export function doFormatText(text, options) { const tokens = new Map(); // replace important words and phrases with tokens - output = autolinkAtMentions(output, tokens); + if (options.usernameMap) { + output = autolinkAtMentions(output, tokens, options.usernameMap); + } + output = autolinkEmails(output, tokens); output = autolinkHashtags(output, tokens); @@ -66,12 +68,12 @@ export function doFormatText(text, options) { output = Emoticons.handleEmoticons(output, tokens, options.emojis || EmojiStore.getEmojis()); } - if (options.searchTerm) { - output = highlightSearchTerms(output, tokens, options.searchTerm); + if (options.searchPatterns) { + output = highlightSearchTerms(output, tokens, options.searchPatterns); } if (!('mentionHighlight' in options) || options.mentionHighlight) { - output = highlightCurrentMentions(output, tokens); + output = highlightCurrentMentions(output, tokens, options.mentionKeys); } if (!('emoticons' in options) || options.emoticon) { @@ -143,10 +145,10 @@ function autolinkEmails(text, tokens) { const punctuation = XRegExp.cache('[^\\pL\\d]'); -function autolinkAtMentions(text, tokens) { +function autolinkAtMentions(text, tokens, usernameMap) { // Test if provided text needs to be highlighted, special mention or current user function mentionExists(u) { - return (Constants.SPECIAL_MENTIONS.indexOf(u) !== -1 || UserStore.getProfileByUsername(u)); + return (Constants.SPECIAL_MENTIONS.indexOf(u) !== -1 || !!usernameMap[u]); } function addToken(username, mention) { @@ -200,12 +202,9 @@ export function escapeRegex(text) { return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } -function highlightCurrentMentions(text, tokens) { +function highlightCurrentMentions(text, tokens, mentionKeys = []) { let output = text; - const mentionKeys = UserStore.getCurrentMentionKeys(); - mentionKeys.push('@here'); - // look for any existing tokens which are self mentions and should be highlighted var newTokens = new Map(); for (const [alias, token] of tokens) { @@ -239,7 +238,7 @@ function highlightCurrentMentions(text, tokens) { return prefix + alias; } - for (const mention of UserStore.getCurrentMentionKeys()) { + for (const mention of mentionKeys) { if (!mention) { continue; } @@ -369,10 +368,8 @@ function convertSearchTermToRegex(term) { return new RegExp(pattern, 'gi'); } -export function highlightSearchTerms(text, tokens, searchTerm) { - const terms = parseSearchTerms(searchTerm); - - if (terms.length === 0) { +export function highlightSearchTerms(text, tokens, searchPatterns) { + if (!searchPatterns || searchPatterns.length === 0) { return text; } @@ -390,13 +387,11 @@ export function highlightSearchTerms(text, tokens, searchTerm) { return prefix + alias; } - for (const term of terms) { + for (const pattern of searchPatterns) { // highlight existing tokens matching search terms - const trimmedTerm = term.replace(/\*$/, '').toLowerCase(); var newTokens = new Map(); for (const [alias, token] of tokens) { - if (token.originalText.toLowerCase() === trimmedTerm || - (token.hashtag && token.hashtag.toLowerCase() === trimmedTerm)) { + if (pattern.test(token.originalText)) { const index = tokens.size + newTokens.size; const newAlias = `MM_SEARCHTERM${index}`; @@ -414,7 +409,7 @@ export function highlightSearchTerms(text, tokens, searchTerm) { tokens.set(newToken[0], newToken[1]); } - output = output.replace(convertSearchTermToRegex(term), replaceSearchTermWithToken); + output = output.replace(pattern, replaceSearchTermWithToken); } return output; @@ -438,32 +433,6 @@ function replaceNewlines(text) { return text.replace(/\n/g, ' '); } -// A click handler that can be used with the results of TextFormatting.formatText to add default functionality -// to clicked hashtags and @mentions. -export function handleClick(e) { - const mentionAttribute = e.target.getAttributeNode('data-mention'); - const hashtagAttribute = e.target.getAttributeNode('data-hashtag'); - const linkAttribute = e.target.getAttributeNode('data-link'); - - if (mentionAttribute) { - e.preventDefault(); - - Utils.searchForTerm(mentionAttribute.value); - } else if (hashtagAttribute) { - e.preventDefault(); - - Utils.searchForTerm(hashtagAttribute.value); - } else if (linkAttribute) { - const MIDDLE_MOUSE_BUTTON = 1; - - if (!(e.button === MIDDLE_MOUSE_BUTTON || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) { - e.preventDefault(); - - browserHistory.push(linkAttribute.value); - } - } -} - //replace all "/" inside <a> tags to "/<wbr />" function insertLongLinkWbr(test) { return test.replace(/\//g, (match, position, string) => { diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index a880c1783..3059ce529 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -1344,3 +1344,27 @@ export function isValidPassword(password) { export function getSiteURL() { return global.mm_config.SiteURL || window.location.origin; } + +export function handleFormattedTextClick(e) { + const mentionAttribute = e.target.getAttributeNode('data-mention'); + const hashtagAttribute = e.target.getAttributeNode('data-hashtag'); + const linkAttribute = e.target.getAttributeNode('data-link'); + + if (mentionAttribute) { + e.preventDefault(); + + searchForTerm(mentionAttribute.value); + } else if (hashtagAttribute) { + e.preventDefault(); + + searchForTerm(hashtagAttribute.value); + } else if (linkAttribute) { + const MIDDLE_MOUSE_BUTTON = 1; + + if (!(e.button === MIDDLE_MOUSE_BUTTON || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) { + e.preventDefault(); + + browserHistory.push(linkAttribute.value); + } + } +}
\ No newline at end of file |