summaryrefslogtreecommitdiffstats
path: root/webapp
diff options
context:
space:
mode:
Diffstat (limited to 'webapp')
-rw-r--r--webapp/actions/global_actions.jsx3
-rw-r--r--webapp/components/post_view/components/post_message_container.jsx18
-rw-r--r--webapp/components/post_view/components/post_message_view.jsx9
-rw-r--r--webapp/components/suggestion/channel_mention_provider.jsx130
-rw-r--r--webapp/components/textbox.jsx3
-rw-r--r--webapp/i18n/en.json2
-rw-r--r--webapp/sass/components/_mentions.scss10
-rw-r--r--webapp/stores/channel_store.jsx23
-rw-r--r--webapp/utils/constants.jsx2
-rw-r--r--webapp/utils/text_formatting.jsx63
-rw-r--r--webapp/utils/utils.jsx3
11 files changed, 257 insertions, 9 deletions
diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx
index 87bd20165..3b38d16b0 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 4ab556fca..749af4ecc 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 (
<PostMessageView
@@ -81,7 +94,8 @@ export default class PostMessageContainer extends React.Component {
enableFormatting={this.state.enableFormatting}
mentionKeys={this.state.mentionKeys}
usernameMap={this.state.usernameMap}
+ channelNamesMap={this.state.channelNamesMap}
/>
);
}
-} \ 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 99589c973..5242e6648 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 000000000..9e8a7b47b
--- /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 (
+ <div
+ className={className}
+ onClick={this.handleClick}
+ >
+ <div className='mention__align'>
+ <span>
+ {channelName}
+ </span>
+ <span className='mention__channelname'>
+ {' '}
+ {description}
+ </span>
+ </div>
+ <div className='mention__purpose'>
+ {purpose}
+ </div>
+ </div>
+ );
+ }
+}
+
+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 12f111833..5c1d823b5 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 74ff88542..91760fb14 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 5df6e4431..4ddb861ca 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 0f2ef9dc0..f1cd0bf82 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 602b6ae4e..9e3eac5c0 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 f97c74625..174620d47 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: `<a class='mention-link' href='#' data-channel-mention="${channelName}">${displayName}</a>`,
+ 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 4dc9aab86..da0e8b154 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);
}
}