From 8443ca58289055cde25a3cdaaa3987c6f8cfde4a Mon Sep 17 00:00:00 2001 From: George Goldberg Date: Mon, 19 Sep 2016 13:21:22 +0100 Subject: [PATCH] PLT-1759 - Auto-complete for !channels when posting messages. (#3890) * Auto-complete for !channels when posting messages. This is part 1 of the fix for PLT-1759 to make channels linkable. Still to do: - Make the !channels clickable when they appear in messages. This is blocked until PR #3865 is resolved as it looks like that refactors some of the code that would be touched by making this change. - Unit tests. Again, I think the above referenced PR should be merged before tackling this. * Fix style problems. * Highlighting of !channel-names in messages. This only identifies the !channel-name (not the display name). The implementation of the auto-complete on channel names now needs to be modified to convert to the channel handle before sending the message. * Display !channel-name as !Display Name. When we encounter !channel-name in a message, display it as a link using the channel's actual name rather than it's handle (name). * Match on names and display name, and use name. * Autocomplete channels matching on both the name and the the display name. * Use the name as the text we fill in instead of the display name. It's potentially a bit ugly, but it minimises complexity for now as otherwise we'd have to do complicated things to the message box. * Fix style issues. * Load more channels everywhere. Whenever we load the list of channels, we should also load the list of more channels. This is to enable auto-completing and auto-linking of all channels whether or not the user is in them currently. * Include more channels in the map for linking. * Listen for channel list updates for autolinking. * Remove accidental console.log. * Autocomplete on more channels too. * i18n for channel autocomplete. * Link directly to channels in !channel mentions. This currently does not work if you aren't a member of that channel. Need to decide what the correct behaviour is in that case. * Fix style issues. * Show channel name and handle in suggestion. * Match channels only at start or after space. * Better matching in text-formatting. Only match channels after a space-type character or at the start in the posts list too. * Move the route construction to make tests work. Moves route-construction out of text_formatting.jsx and into utils.jsx so that the unit tests work once again. --- webapp/actions/global_actions.jsx | 3 + .../components/post_message_container.jsx | 18 ++- .../components/post_message_view.jsx | 9 +- .../suggestion/channel_mention_provider.jsx | 130 ++++++++++++++++++ webapp/components/textbox.jsx | 3 +- webapp/i18n/en.json | 2 + webapp/sass/components/_mentions.scss | 10 ++ webapp/stores/channel_store.jsx | 23 ++++ webapp/utils/constants.jsx | 2 + webapp/utils/text_formatting.jsx | 63 ++++++++- webapp/utils/utils.jsx | 3 + 11 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 webapp/components/suggestion/channel_mention_provider.jsx diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index 87bd201656..3b38d16b0d 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -42,6 +42,7 @@ export function emitChannelClickEvent(channel) { } function switchToChannel(chan) { AsyncClient.getChannels(true); + AsyncClient.getMoreChannels(true); AsyncClient.getChannelExtraInfo(chan.id); AsyncClient.updateLastViewedAt(chan.id); AsyncClient.getPosts(chan.id); @@ -141,6 +142,7 @@ export function doFocusPost(channelId, postId, data) { post_list: data }); AsyncClient.getChannels(true); + AsyncClient.getMoreChannels(true); AsyncClient.getChannelExtraInfo(channelId); AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); @@ -435,6 +437,7 @@ export function loadDefaultLocale() { export function viewLoggedIn() { AsyncClient.getChannels(); + AsyncClient.getMoreChannels(); AsyncClient.getChannelExtraInfo(); // Clear pending posts (shouldn't have pending posts if we are loading) diff --git a/webapp/components/post_view/components/post_message_container.jsx b/webapp/components/post_view/components/post_message_container.jsx index 4ab556fcaa..749af4ecc6 100644 --- a/webapp/components/post_view/components/post_message_container.jsx +++ b/webapp/components/post_view/components/post_message_container.jsx @@ -3,6 +3,7 @@ import React from 'react'; +import ChannelStore from 'stores/channel_store.jsx'; import EmojiStore from 'stores/emoji_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import {Preferences} from 'utils/constants.jsx'; @@ -26,6 +27,7 @@ export default class PostMessageContainer extends React.Component { this.onEmojiChange = this.onEmojiChange.bind(this); this.onPreferenceChange = this.onPreferenceChange.bind(this); this.onUserChange = this.onUserChange.bind(this); + this.onChannelChange = this.onChannelChange.bind(this); const mentionKeys = UserStore.getCurrentMentionKeys(); mentionKeys.push('@here'); @@ -34,7 +36,8 @@ export default class PostMessageContainer extends React.Component { emojis: EmojiStore.getEmojis(), enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), mentionKeys, - usernameMap: UserStore.getProfilesUsernameMap() + usernameMap: UserStore.getProfilesUsernameMap(), + channelNamesMap: ChannelStore.getChannelNamesMap() }; } @@ -42,12 +45,16 @@ export default class PostMessageContainer extends React.Component { EmojiStore.addChangeListener(this.onEmojiChange); PreferenceStore.addChangeListener(this.onPreferenceChange); UserStore.addChangeListener(this.onUserChange); + ChannelStore.addChangeListener(this.onChannelChange); + ChannelStore.addMoreChangeListener(this.onChannelChange); } componentWillUnmount() { EmojiStore.removeChangeListener(this.onEmojiChange); PreferenceStore.removeChangeListener(this.onPreferenceChange); UserStore.removeChangeListener(this.onUserChange); + ChannelStore.removeChangeListener(this.onChannelChange); + ChannelStore.removeMoreChangeListener(this.onChannelChange); } onEmojiChange() { @@ -72,6 +79,12 @@ export default class PostMessageContainer extends React.Component { }); } + onChannelChange() { + this.setState({ + channelNamesMap: ChannelStore.getChannelNamesMap() + }); + } + render() { return ( ); } -} \ 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 index 99589c973f..5242e66485 100644 --- a/webapp/components/post_view/components/post_message_view.jsx +++ b/webapp/components/post_view/components/post_message_view.jsx @@ -13,7 +13,8 @@ export default class PostMessageView extends React.Component { emojis: React.PropTypes.object.isRequired, enableFormatting: React.PropTypes.bool.isRequired, mentionKeys: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, - usernameMap: React.PropTypes.object.isRequired + usernameMap: React.PropTypes.object.isRequired, + channelNamesMap: React.PropTypes.object.isRequired }; shouldComponentUpdate(nextProps) { @@ -40,6 +41,7 @@ export default class PostMessageView extends React.Component { // 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. + // The same choice (and reasoning) is also applied to the this.props.channelNamesMap. return false; } @@ -53,7 +55,8 @@ export default class PostMessageView extends React.Component { emojis: this.props.emojis, siteURL: Utils.getSiteURL(), mentionKeys: this.props.mentionKeys, - usernameMap: this.props.usernameMap + usernameMap: this.props.usernameMap, + channelNamesMap: this.props.channelNamesMap }); return ( @@ -63,4 +66,4 @@ export default class PostMessageView extends React.Component { /> ); } -} \ No newline at end of file +} diff --git a/webapp/components/suggestion/channel_mention_provider.jsx b/webapp/components/suggestion/channel_mention_provider.jsx new file mode 100644 index 0000000000..9e8a7b47b2 --- /dev/null +++ b/webapp/components/suggestion/channel_mention_provider.jsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import SuggestionStore from 'stores/suggestion_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import Constants from 'utils/constants.jsx'; + +import Suggestion from './suggestion.jsx'; + +const MaxChannelSuggestions = 40; + +class ChannelMentionSuggestion extends Suggestion { + render() { + const isSelection = this.props.isSelection; + const item = this.props.item; + + const channelName = item.channel.display_name; + let purpose = item.channel.purpose; + + let className = 'mentions__name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + const description = '(!' + item.channel.name + ')'; + + return ( +
+
+ + {channelName} + + + {' '} + {description} + +
+
+ {purpose} +
+
+ ); + } +} + +function filterChannelsByPrefix(channels, prefix, limit) { + const filtered = []; + + for (const id of Object.keys(channels)) { + if (filtered.length >= limit) { + break; + } + + const channel = channels[id]; + + if (channel.delete_at > 0) { + continue; + } + + if (channel.display_name.toLowerCase().startsWith(prefix) || channel.name.startsWith(prefix)) { + filtered.push(channel); + } + } + + return filtered; +} + +export default class ChannelMentionProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/(^|\s)(!([^!]*))$/i).exec(pretext.toLowerCase()); + if (captured) { + const prefix = captured[3]; + + const channels = ChannelStore.getAll(); + const moreChannels = ChannelStore.getMoreAll(); + + // Remove private channels from the list. + const publicChannels = channels.filter((channel) => { + return channel.type === 'O'; + }); + + // Filter channels by prefix. + const filteredChannels = filterChannelsByPrefix( + publicChannels, prefix, MaxChannelSuggestions); + const filteredMoreChannels = filterChannelsByPrefix( + moreChannels, prefix, MaxChannelSuggestions - filteredChannels.length); + + // Sort channels by display name. + [filteredChannels, filteredMoreChannels].forEach((items) => { + items.sort((a, b) => { + const aPrefix = a.display_name.startsWith(prefix); + const bPrefix = b.display_name.startsWith(prefix); + + if (aPrefix === bPrefix) { + return a.display_name.localeCompare(b.display_name); + } else if (aPrefix) { + return -1; + } + + return 1; + }); + }); + + // Wrap channels in an outer object to avoid overwriting the 'type' property. + const wrappedChannels = filteredChannels.map((item) => { + return { + type: Constants.MENTION_CHANNELS, + channel: item + }; + }); + const wrappedMoreChannels = filteredMoreChannels.map((item) => { + return { + type: Constants.MENTION_MORE_CHANNELS, + channel: item + }; + }); + + const wrapped = wrappedChannels.concat(wrappedMoreChannels); + + const mentions = wrapped.map((item) => '!' + item.channel.name); + + SuggestionStore.addSuggestions(suggestionId, mentions, wrapped, ChannelMentionSuggestion, captured[2]); + } + } +} diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx index 12f1118336..5c1d823b53 100644 --- a/webapp/components/textbox.jsx +++ b/webapp/components/textbox.jsx @@ -3,6 +3,7 @@ import $ from 'jquery'; import AtMentionProvider from './suggestion/at_mention_provider.jsx'; +import ChannelMentionProvider from './suggestion/channel_mention_provider.jsx'; import CommandProvider from './suggestion/command_provider.jsx'; import EmoticonProvider from './suggestion/emoticon_provider.jsx'; import SuggestionList from './suggestion/suggestion_list.jsx'; @@ -35,7 +36,7 @@ export default class Textbox extends React.Component { connection: '' }; - this.suggestionProviders = [new AtMentionProvider(), new EmoticonProvider()]; + this.suggestionProviders = [new AtMentionProvider(), new ChannelMentionProvider(), new EmoticonProvider()]; if (props.supportsCommands) { this.suggestionProviders.push(new CommandProvider()); } diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 74ff885421..91760fb14e 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1697,8 +1697,10 @@ "sso_signup.team_error": "Please enter a team name", "suggestion.mention.all": "Notifies everyone in the channel, use in {townsquare} to notify the whole team", "suggestion.mention.channel": "Notifies everyone in the channel", + "suggestion.mention.channels": "My Channels", "suggestion.mention.here": "Notifies everyone in the channel and online", "suggestion.mention.members": "Channel Members", + "suggestion.mention.morechannels": "Other Channels", "suggestion.mention.nonmembers": "Not in Channel", "suggestion.mention.special": "Special Mentions", "suggestion.search.private": "Private Groups", diff --git a/webapp/sass/components/_mentions.scss b/webapp/sass/components/_mentions.scss index 5df6e4431e..4ddb861caa 100644 --- a/webapp/sass/components/_mentions.scss +++ b/webapp/sass/components/_mentions.scss @@ -54,3 +54,13 @@ .mention--highlight { background-color: $yellow; } + +.mention__purpose { + @include opacity(.5); + line-height: normal; + margin-left: 5px; +} + +.mention__channelname { + @include opacity(.5); +} diff --git a/webapp/stores/channel_store.jsx b/webapp/stores/channel_store.jsx index 0f2ef9dc08..f1cd0bf82a 100644 --- a/webapp/stores/channel_store.jsx +++ b/webapp/stores/channel_store.jsx @@ -47,6 +47,7 @@ class ChannelStoreClass extends EventEmitter { this.setUnreadCounts = this.setUnreadCounts.bind(this); this.getUnreadCount = this.getUnreadCount.bind(this); this.getUnreadCounts = this.getUnreadCounts.bind(this); + this.getChannelNamesMap = this.getChannelNamesMap.bind(this); this.currentId = null; this.postMode = this.POST_MODE_CHANNEL; @@ -358,6 +359,28 @@ class ChannelStoreClass extends EventEmitter { this.channels.splice(element, 1); } } + + getChannelNamesMap() { + var channelNamesMap = {}; + + var channels = this.getChannels(); + for (var key in channels) { + if (channels.hasOwnProperty(key)) { + var channel = channels[key]; + channelNamesMap[channel.name] = channel; + } + } + + var moreChannels = this.getMoreChannels(); + for (var moreKey in moreChannels) { + if (moreChannels.hasOwnProperty(moreKey)) { + var moreChannel = moreChannels[moreKey]; + channelNamesMap[moreChannel.name] = moreChannel; + } + } + + return channelNamesMap; + } } var ChannelStore = new ChannelStoreClass(); diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 602b6ae4eb..9e3eac5c0d 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -835,6 +835,8 @@ export const Constants = { PERMISSIONS_ALL: 'all', PERMISSIONS_TEAM_ADMIN: 'team_admin', PERMISSIONS_SYSTEM_ADMIN: 'system_admin', + MENTION_CHANNELS: 'mention.channels', + MENTION_MORE_CHANNELS: 'mention.morechannels', MENTION_MEMBERS: 'mention.members', MENTION_NONMEMBERS: 'mention.nonmembers', MENTION_SPECIAL: 'mention.special', diff --git a/webapp/utils/text_formatting.jsx b/webapp/utils/text_formatting.jsx index f97c74625d..174620d47f 100644 --- a/webapp/utils/text_formatting.jsx +++ b/webapp/utils/text_formatting.jsx @@ -13,9 +13,9 @@ import XRegExp from 'xregexp'; // http://stackoverflow.com/questions/15033196/using-javascript-to-check-whether-a-string-contains-japanese-characters-includi const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]/; -// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and -// @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options -// as part of the second parameter: +// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, +// @mentions and !channels to links by taking a user's message and returning a string of formatted html. Also takes +// a number of options 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. @@ -26,6 +26,8 @@ const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00- // 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) +// - channelNamesMap - An object mapping channel display names to channels. If provided, !channel mentions will be replaced with +// links to the relevant channel. export function formatText(text, inputOptions) { let output = text; @@ -61,6 +63,10 @@ export function doFormatText(text, options) { output = autolinkAtMentions(output, tokens, options.usernameMap); } + if (options.channelNamesMap) { + output = autolinkChannelMentions(output, tokens, options.channelNamesMap); + } + output = autolinkEmails(output, tokens); output = autolinkHashtags(output, tokens); @@ -198,6 +204,57 @@ function autolinkAtMentions(text, tokens, usernameMap) { return output; } +function autolinkChannelMentions(text, tokens, channelNamesMap) { + function channelMentionExists(c) { + return !!channelNamesMap[c]; + } + function addToken(channelName, mention, displayName) { + const index = tokens.size; + const alias = `MM_CHANNELMENTION${index}`; + + tokens.set(alias, { + value: `${displayName}`, + originalText: mention + }); + return alias; + } + + function replaceChannelMentionWithToken(fullMatch, spacer, mention, channelName) { + let channelNameLower = channelName.toLowerCase(); + + if (channelMentionExists(channelNameLower)) { + // Exact match + const alias = addToken(channelNameLower, mention, '!' + channelNamesMap[channelNameLower].display_name); + return spacer + alias; + } + + // Not an exact match, attempt to truncate any punctuation to see if we can find a channel + const originalChannelName = channelNameLower; + + for (let c = channelNameLower.length; c > 0; c--) { + if (punctuation.test(channelNameLower[c - 1])) { + channelNameLower = channelNameLower.substring(0, c - 1); + + if (channelMentionExists(channelNameLower)) { + const suffix = originalChannelName.substr(c - 1); + const alias = addToken(channelNameLower, '!' + channelNameLower, '!' + channelNamesMap[channelNameLower].display_name); + return spacer + alias + suffix; + } + } else { + // If the last character is not punctuation, no point in going any further + break; + } + } + + return fullMatch; + } + + let output = text; + output = output.replace(/(^|\s)(!([a-z0-9.\-_]*))/gi, replaceChannelMentionWithToken); + + return output; +} + export function escapeRegex(text) { return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 4dc9aab862..da0e8b1543 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -1355,6 +1355,7 @@ 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'); + const channelMentionAttribute = e.target.getAttributeNode('data-channel-mention'); if (mentionAttribute) { e.preventDefault(); @@ -1372,5 +1373,7 @@ export function handleFormattedTextClick(e) { browserHistory.push(linkAttribute.value); } + } else if (channelMentionAttribute) { + browserHistory.push('/' + TeamStore.getCurrent().name + '/channels/' + channelMentionAttribute.value); } }