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.
Этот коммит содержится в:
коммит произвёл
Joram Wilander
родитель
781ff323db
Коммит
8443ca5828
@@ -42,6 +42,7 @@ export function emitChannelClickEvent(channel) {
|
|||||||
}
|
}
|
||||||
function switchToChannel(chan) {
|
function switchToChannel(chan) {
|
||||||
AsyncClient.getChannels(true);
|
AsyncClient.getChannels(true);
|
||||||
|
AsyncClient.getMoreChannels(true);
|
||||||
AsyncClient.getChannelExtraInfo(chan.id);
|
AsyncClient.getChannelExtraInfo(chan.id);
|
||||||
AsyncClient.updateLastViewedAt(chan.id);
|
AsyncClient.updateLastViewedAt(chan.id);
|
||||||
AsyncClient.getPosts(chan.id);
|
AsyncClient.getPosts(chan.id);
|
||||||
@@ -141,6 +142,7 @@ export function doFocusPost(channelId, postId, data) {
|
|||||||
post_list: data
|
post_list: data
|
||||||
});
|
});
|
||||||
AsyncClient.getChannels(true);
|
AsyncClient.getChannels(true);
|
||||||
|
AsyncClient.getMoreChannels(true);
|
||||||
AsyncClient.getChannelExtraInfo(channelId);
|
AsyncClient.getChannelExtraInfo(channelId);
|
||||||
AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true);
|
AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true);
|
||||||
AsyncClient.getPostsAfter(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() {
|
export function viewLoggedIn() {
|
||||||
AsyncClient.getChannels();
|
AsyncClient.getChannels();
|
||||||
|
AsyncClient.getMoreChannels();
|
||||||
AsyncClient.getChannelExtraInfo();
|
AsyncClient.getChannelExtraInfo();
|
||||||
|
|
||||||
// Clear pending posts (shouldn't have pending posts if we are loading)
|
// Clear pending posts (shouldn't have pending posts if we are loading)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import ChannelStore from 'stores/channel_store.jsx';
|
||||||
import EmojiStore from 'stores/emoji_store.jsx';
|
import EmojiStore from 'stores/emoji_store.jsx';
|
||||||
import PreferenceStore from 'stores/preference_store.jsx';
|
import PreferenceStore from 'stores/preference_store.jsx';
|
||||||
import {Preferences} from 'utils/constants.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.onEmojiChange = this.onEmojiChange.bind(this);
|
||||||
this.onPreferenceChange = this.onPreferenceChange.bind(this);
|
this.onPreferenceChange = this.onPreferenceChange.bind(this);
|
||||||
this.onUserChange = this.onUserChange.bind(this);
|
this.onUserChange = this.onUserChange.bind(this);
|
||||||
|
this.onChannelChange = this.onChannelChange.bind(this);
|
||||||
|
|
||||||
const mentionKeys = UserStore.getCurrentMentionKeys();
|
const mentionKeys = UserStore.getCurrentMentionKeys();
|
||||||
mentionKeys.push('@here');
|
mentionKeys.push('@here');
|
||||||
@@ -34,7 +36,8 @@ export default class PostMessageContainer extends React.Component {
|
|||||||
emojis: EmojiStore.getEmojis(),
|
emojis: EmojiStore.getEmojis(),
|
||||||
enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true),
|
enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true),
|
||||||
mentionKeys,
|
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);
|
EmojiStore.addChangeListener(this.onEmojiChange);
|
||||||
PreferenceStore.addChangeListener(this.onPreferenceChange);
|
PreferenceStore.addChangeListener(this.onPreferenceChange);
|
||||||
UserStore.addChangeListener(this.onUserChange);
|
UserStore.addChangeListener(this.onUserChange);
|
||||||
|
ChannelStore.addChangeListener(this.onChannelChange);
|
||||||
|
ChannelStore.addMoreChangeListener(this.onChannelChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
EmojiStore.removeChangeListener(this.onEmojiChange);
|
EmojiStore.removeChangeListener(this.onEmojiChange);
|
||||||
PreferenceStore.removeChangeListener(this.onPreferenceChange);
|
PreferenceStore.removeChangeListener(this.onPreferenceChange);
|
||||||
UserStore.removeChangeListener(this.onUserChange);
|
UserStore.removeChangeListener(this.onUserChange);
|
||||||
|
ChannelStore.removeChangeListener(this.onChannelChange);
|
||||||
|
ChannelStore.removeMoreChangeListener(this.onChannelChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEmojiChange() {
|
onEmojiChange() {
|
||||||
@@ -72,6 +79,12 @@ export default class PostMessageContainer extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onChannelChange() {
|
||||||
|
this.setState({
|
||||||
|
channelNamesMap: ChannelStore.getChannelNamesMap()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<PostMessageView
|
<PostMessageView
|
||||||
@@ -81,7 +94,8 @@ export default class PostMessageContainer extends React.Component {
|
|||||||
enableFormatting={this.state.enableFormatting}
|
enableFormatting={this.state.enableFormatting}
|
||||||
mentionKeys={this.state.mentionKeys}
|
mentionKeys={this.state.mentionKeys}
|
||||||
usernameMap={this.state.usernameMap}
|
usernameMap={this.state.usernameMap}
|
||||||
|
channelNamesMap={this.state.channelNamesMap}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default class PostMessageView extends React.Component {
|
|||||||
emojis: React.PropTypes.object.isRequired,
|
emojis: React.PropTypes.object.isRequired,
|
||||||
enableFormatting: React.PropTypes.bool.isRequired,
|
enableFormatting: React.PropTypes.bool.isRequired,
|
||||||
mentionKeys: React.PropTypes.arrayOf(React.PropTypes.string).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) {
|
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.
|
// 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.
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,8 @@ export default class PostMessageView extends React.Component {
|
|||||||
emojis: this.props.emojis,
|
emojis: this.props.emojis,
|
||||||
siteURL: Utils.getSiteURL(),
|
siteURL: Utils.getSiteURL(),
|
||||||
mentionKeys: this.props.mentionKeys,
|
mentionKeys: this.props.mentionKeys,
|
||||||
usernameMap: this.props.usernameMap
|
usernameMap: this.props.usernameMap,
|
||||||
|
channelNamesMap: this.props.channelNamesMap
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,4 +66,4 @@ export default class PostMessageView extends React.Component {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
webapp/components/suggestion/channel_mention_provider.jsx
Обычный файл
130
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import AtMentionProvider from './suggestion/at_mention_provider.jsx';
|
import AtMentionProvider from './suggestion/at_mention_provider.jsx';
|
||||||
|
import ChannelMentionProvider from './suggestion/channel_mention_provider.jsx';
|
||||||
import CommandProvider from './suggestion/command_provider.jsx';
|
import CommandProvider from './suggestion/command_provider.jsx';
|
||||||
import EmoticonProvider from './suggestion/emoticon_provider.jsx';
|
import EmoticonProvider from './suggestion/emoticon_provider.jsx';
|
||||||
import SuggestionList from './suggestion/suggestion_list.jsx';
|
import SuggestionList from './suggestion/suggestion_list.jsx';
|
||||||
@@ -35,7 +36,7 @@ export default class Textbox extends React.Component {
|
|||||||
connection: ''
|
connection: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
this.suggestionProviders = [new AtMentionProvider(), new EmoticonProvider()];
|
this.suggestionProviders = [new AtMentionProvider(), new ChannelMentionProvider(), new EmoticonProvider()];
|
||||||
if (props.supportsCommands) {
|
if (props.supportsCommands) {
|
||||||
this.suggestionProviders.push(new CommandProvider());
|
this.suggestionProviders.push(new CommandProvider());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1697,8 +1697,10 @@
|
|||||||
"sso_signup.team_error": "Please enter a team name",
|
"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.all": "Notifies everyone in the channel, use in {townsquare} to notify the whole team",
|
||||||
"suggestion.mention.channel": "Notifies everyone in the channel",
|
"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.here": "Notifies everyone in the channel and online",
|
||||||
"suggestion.mention.members": "Channel Members",
|
"suggestion.mention.members": "Channel Members",
|
||||||
|
"suggestion.mention.morechannels": "Other Channels",
|
||||||
"suggestion.mention.nonmembers": "Not in Channel",
|
"suggestion.mention.nonmembers": "Not in Channel",
|
||||||
"suggestion.mention.special": "Special Mentions",
|
"suggestion.mention.special": "Special Mentions",
|
||||||
"suggestion.search.private": "Private Groups",
|
"suggestion.search.private": "Private Groups",
|
||||||
|
|||||||
@@ -54,3 +54,13 @@
|
|||||||
.mention--highlight {
|
.mention--highlight {
|
||||||
background-color: $yellow;
|
background-color: $yellow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention__purpose {
|
||||||
|
@include opacity(.5);
|
||||||
|
line-height: normal;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention__channelname {
|
||||||
|
@include opacity(.5);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ChannelStoreClass extends EventEmitter {
|
|||||||
this.setUnreadCounts = this.setUnreadCounts.bind(this);
|
this.setUnreadCounts = this.setUnreadCounts.bind(this);
|
||||||
this.getUnreadCount = this.getUnreadCount.bind(this);
|
this.getUnreadCount = this.getUnreadCount.bind(this);
|
||||||
this.getUnreadCounts = this.getUnreadCounts.bind(this);
|
this.getUnreadCounts = this.getUnreadCounts.bind(this);
|
||||||
|
this.getChannelNamesMap = this.getChannelNamesMap.bind(this);
|
||||||
|
|
||||||
this.currentId = null;
|
this.currentId = null;
|
||||||
this.postMode = this.POST_MODE_CHANNEL;
|
this.postMode = this.POST_MODE_CHANNEL;
|
||||||
@@ -358,6 +359,28 @@ class ChannelStoreClass extends EventEmitter {
|
|||||||
this.channels.splice(element, 1);
|
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();
|
var ChannelStore = new ChannelStoreClass();
|
||||||
|
|||||||
@@ -835,6 +835,8 @@ export const Constants = {
|
|||||||
PERMISSIONS_ALL: 'all',
|
PERMISSIONS_ALL: 'all',
|
||||||
PERMISSIONS_TEAM_ADMIN: 'team_admin',
|
PERMISSIONS_TEAM_ADMIN: 'team_admin',
|
||||||
PERMISSIONS_SYSTEM_ADMIN: 'system_admin',
|
PERMISSIONS_SYSTEM_ADMIN: 'system_admin',
|
||||||
|
MENTION_CHANNELS: 'mention.channels',
|
||||||
|
MENTION_MORE_CHANNELS: 'mention.morechannels',
|
||||||
MENTION_MEMBERS: 'mention.members',
|
MENTION_MEMBERS: 'mention.members',
|
||||||
MENTION_NONMEMBERS: 'mention.nonmembers',
|
MENTION_NONMEMBERS: 'mention.nonmembers',
|
||||||
MENTION_SPECIAL: 'mention.special',
|
MENTION_SPECIAL: 'mention.special',
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import XRegExp from 'xregexp';
|
|||||||
// http://stackoverflow.com/questions/15033196/using-javascript-to-check-whether-a-string-contains-japanese-characters-includi
|
// 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]/;
|
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
|
// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags,
|
||||||
// @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options
|
// @mentions and !channels to links by taking a user's message and returning a string of formatted html. Also takes
|
||||||
// as part of the second parameter:
|
// a number of options as part of the second parameter:
|
||||||
// - searchTerm - If specified, this word is highlighted in the resulting html. Defaults to nothing.
|
// - 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.
|
// - 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.
|
// - 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.
|
// 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
|
// - 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)
|
// 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) {
|
export function formatText(text, inputOptions) {
|
||||||
let output = text;
|
let output = text;
|
||||||
|
|
||||||
@@ -61,6 +63,10 @@ export function doFormatText(text, options) {
|
|||||||
output = autolinkAtMentions(output, tokens, options.usernameMap);
|
output = autolinkAtMentions(output, tokens, options.usernameMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.channelNamesMap) {
|
||||||
|
output = autolinkChannelMentions(output, tokens, options.channelNamesMap);
|
||||||
|
}
|
||||||
|
|
||||||
output = autolinkEmails(output, tokens);
|
output = autolinkEmails(output, tokens);
|
||||||
output = autolinkHashtags(output, tokens);
|
output = autolinkHashtags(output, tokens);
|
||||||
|
|
||||||
@@ -198,6 +204,57 @@ function autolinkAtMentions(text, tokens, usernameMap) {
|
|||||||
return output;
|
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) {
|
export function escapeRegex(text) {
|
||||||
return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1355,6 +1355,7 @@ export function handleFormattedTextClick(e) {
|
|||||||
const mentionAttribute = e.target.getAttributeNode('data-mention');
|
const mentionAttribute = e.target.getAttributeNode('data-mention');
|
||||||
const hashtagAttribute = e.target.getAttributeNode('data-hashtag');
|
const hashtagAttribute = e.target.getAttributeNode('data-hashtag');
|
||||||
const linkAttribute = e.target.getAttributeNode('data-link');
|
const linkAttribute = e.target.getAttributeNode('data-link');
|
||||||
|
const channelMentionAttribute = e.target.getAttributeNode('data-channel-mention');
|
||||||
|
|
||||||
if (mentionAttribute) {
|
if (mentionAttribute) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1372,5 +1373,7 @@ export function handleFormattedTextClick(e) {
|
|||||||
|
|
||||||
browserHistory.push(linkAttribute.value);
|
browserHistory.push(linkAttribute.value);
|
||||||
}
|
}
|
||||||
|
} else if (channelMentionAttribute) {
|
||||||
|
browserHistory.push('/' + TeamStore.getCurrent().name + '/channels/' + channelMentionAttribute.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user