diff --git a/app/components/autocomplete/channel_mention/channel_mention.js b/app/components/autocomplete/channel_mention/channel_mention.js
new file mode 100644
index 000000000..ee671b508
--- /dev/null
+++ b/app/components/autocomplete/channel_mention/channel_mention.js
@@ -0,0 +1,270 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {Component, PropTypes} from 'react';
+import {
+ ListView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View
+} from 'react-native';
+
+import FormattedText from 'app/components/formatted_text';
+import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
+
+import {RequestStatus} from 'service/constants';
+
+const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+ return StyleSheet.create({
+ section: {
+ justifyContent: 'center',
+ paddingLeft: 8,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
+ borderTopWidth: 1,
+ borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderLeftWidth: 1,
+ borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderRightWidth: 1,
+ borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
+ },
+ sectionText: {
+ fontSize: 12,
+ color: changeOpacity(theme.centerChannelColor, 0.7),
+ paddingVertical: 7
+ },
+ sectionWrapper: {
+ backgroundColor: theme.centerChannelBg
+ },
+ listView: {
+ flex: 1,
+ backgroundColor: theme.centerChannelBg
+ },
+ loading: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 20,
+ backgroundColor: theme.centerChannelBg,
+ borderWidth: 1,
+ borderColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderBottomWidth: 0
+ },
+ row: {
+ padding: 8,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: theme.centerChannelBg,
+ borderTopWidth: 1,
+ borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderLeftWidth: 1,
+ borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderRightWidth: 1,
+ borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
+ },
+ rowDisplayName: {
+ fontSize: 13,
+ color: theme.centerChannelColor
+ },
+ rowName: {
+ color: theme.centerChannelColor,
+ opacity: 0.6
+ }
+ });
+});
+
+export default class ChannelMention extends Component {
+ static propTypes = {
+ currentChannelId: PropTypes.string.isRequired,
+ currentTeamId: PropTypes.string.isRequired,
+ cursorPosition: PropTypes.number.isRequired,
+ autocompleteChannels: PropTypes.object.isRequired,
+ postDraft: PropTypes.string,
+ requestStatus: PropTypes.string.isRequired,
+ theme: PropTypes.object.isRequired,
+ actions: PropTypes.shape({
+ changePostDraft: PropTypes.func.isRequired,
+ autocompleteChannels: PropTypes.func.isRequired
+ })
+ }
+
+ static defaultProps = {
+ postDraft: ''
+ }
+
+ constructor(props) {
+ super(props);
+
+ const ds = new ListView.DataSource({
+ sectionHeaderHasChanged: (s1, s2) => s1 !== s2,
+ rowHasChanged: (r1, r2) => r1 !== r2
+ });
+
+ this.state = {
+ active: false,
+ dataSource: ds.cloneWithRowsAndSections(props.autocompleteChannels)
+ };
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(CHANNEL_MENTION_REGEX);
+
+ // If not match or if user clicked on a channel
+ if (!match || this.state.mentionComplete) {
+ const nextState = {
+ active: false,
+ mentionComplete: false
+ };
+
+ // Handle the case where the user typed a ~ first and then backspaced
+ if (nextProps.postDraft.length < this.props.postDraft.length) {
+ nextState.matchTerm = null;
+ }
+
+ this.setState(nextState);
+ return;
+ }
+
+ const matchTerm = match[2];
+ const myChannels = nextProps.autocompleteChannels.myChannels;
+ const otherChannels = nextProps.autocompleteChannels.otherChannels;
+
+ // Show loading indicator on first pull for channels
+ if (nextProps.requestStatus === RequestStatus.STARTED && ((myChannels.length === 0 && otherChannels.length === 0) || matchTerm === '')) {
+ this.setState({
+ active: true,
+ loading: true
+ });
+ return;
+ }
+
+ // Still matching the same term that didn't return any results
+ if (match[0].startsWith(`~${this.state.matchTerm}`) && (myChannels.length === 0 && otherChannels.length === 0)) {
+ this.setState({
+ active: false
+ });
+ return;
+ }
+
+ if (matchTerm !== this.state.matchTerm) {
+ this.setState({
+ matchTerm
+ });
+
+ const {currentTeamId} = this.props;
+ this.props.actions.autocompleteChannels(currentTeamId, matchTerm);
+ return;
+ }
+
+ if (nextProps.requestStatus !== RequestStatus.STARTED && this.props.autocompleteChannels !== nextProps.autocompleteChannels) {
+ let data = {};
+ if (myChannels.length > 0) {
+ data = Object.assign({}, data, {myChannels});
+ }
+ if (otherChannels.length > 0) {
+ data = Object.assign({}, data, {otherChannels});
+ }
+
+ this.setState({
+ active: true,
+ loading: false,
+ dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
+ });
+ }
+ }
+
+ completeMention = (mention) => {
+ const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
+
+ let completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
+ if (this.props.postDraft.length > this.props.cursorPosition) {
+ completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
+ }
+
+ this.props.actions.changePostDraft(this.props.currentChannelId, completedDraft);
+ this.setState({
+ active: false,
+ mentionComplete: true,
+ matchTerm: `${mention} `
+ });
+ }
+
+ renderSectionHeader = (sectionData, sectionId) => {
+ const style = getStyleFromTheme(this.props.theme);
+
+ const localization = {
+ myChannels: {
+ id: 'suggestion.mention.channels',
+ defaultMessage: 'My Channels'
+ },
+ otherChannels: {
+ id: 'suggestion.mention.morechannels',
+ defaultMessage: 'Other Channels'
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ renderRow = (data) => {
+ const style = getStyleFromTheme(this.props.theme);
+
+ return (
+ this.completeMention(data.name)}
+ style={style.row}
+ >
+ {data.display_name}
+ {` (~${data.name})`}
+
+ );
+ }
+
+ render() {
+ if (!this.state.active) {
+ // If we are not in an active state return null so nothing is rendered
+ // other components are not blocked.
+ return null;
+ }
+
+ const {requestStatus, theme} = this.props;
+
+ const style = getStyleFromTheme(theme);
+
+ if (this.state.loading && requestStatus === RequestStatus.STARTED) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/app/components/autocomplete/channel_mention/index.js b/app/components/autocomplete/channel_mention/index.js
new file mode 100644
index 000000000..b3714e478
--- /dev/null
+++ b/app/components/autocomplete/channel_mention/index.js
@@ -0,0 +1,36 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {bindActionCreators} from 'redux';
+import {connect} from 'react-redux';
+
+import {handlePostDraftChanged} from 'app/actions/views/channel';
+import {autocompleteChannels} from 'service/actions/channels';
+import {getTheme} from 'service/selectors/entities/preferences';
+import {getAutocompleteChannelWithSections} from 'service/selectors/entities/channels';
+
+import ChannelMention from './channel_mention';
+
+function mapStateToProps(state) {
+ const currentChannelId = state.entities.channels.currentId;
+ const postDraft = state.views.channel.drafts[currentChannelId];
+ return {
+ currentChannelId,
+ currentTeamId: state.entities.teams.currentId,
+ postDraft,
+ autocompleteChannels: getAutocompleteChannelWithSections(state),
+ requestStatus: state.requests.channels.autocompleteChannels.status,
+ theme: getTheme(state)
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ changePostDraft: handlePostDraftChanged,
+ autocompleteChannels
+ }, dispatch)
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ChannelMention);
diff --git a/app/components/autocomplete/index.js b/app/components/autocomplete/index.js
index e14ce3c70..d99b4534f 100644
--- a/app/components/autocomplete/index.js
+++ b/app/components/autocomplete/index.js
@@ -8,6 +8,7 @@ import {
} from 'react-native';
import AtMention from './at_mention';
+import ChannelMention from './channel_mention';
const style = StyleSheet.create({
container: {
@@ -36,6 +37,7 @@ export default class Autocomplete extends Component {
+
);
diff --git a/service/actions/channels.js b/service/actions/channels.js
index 3b6bae838..b52b66e47 100644
--- a/service/actions/channels.js
+++ b/service/actions/channels.js
@@ -653,6 +653,32 @@ export function markChannelAsUnread(channelId, mentionsArray) {
};
}
+export function autocompleteChannels(teamId, term) {
+ return async (dispatch, getState) => {
+ dispatch({type: ChannelTypes.AUTOCOMPLETE_CHANNELS_REQUEST}, getState);
+
+ let data;
+ try {
+ data = await Client.autocompleteChannels(teamId, term);
+ } catch (error) {
+ forceLogoutIfNecessary(error, dispatch);
+ dispatch({type: ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE, error}, getState);
+ return;
+ }
+
+ dispatch(batchActions([
+ {
+ type: ChannelTypes.RECEIVED_AUTOCOMPLETE_CHANNELS,
+ data,
+ teamId
+ },
+ {
+ type: ChannelTypes.AUTOCOMPLETE_CHANNELS_SUCCESS
+ }
+ ]), getState);
+ };
+}
+
export default {
selectChannel,
createChannel,
@@ -674,5 +700,6 @@ export default {
updateChannelHeader,
updateChannelPurpose,
markChannelAsRead,
- markChannelAsUnread
+ markChannelAsUnread,
+ autocompleteChannels
};
diff --git a/service/client/client.js b/service/client/client.js
index fcc4e4976..ed5470e0a 100644
--- a/service/client/client.js
+++ b/service/client/client.js
@@ -360,7 +360,7 @@ export default class Client {
`${this.getChannelNeededRoute(teamId, channelId)}/users/autocomplete?term=${encodeURIComponent(term)}`,
{method: 'get'}
);
- }
+ };
searchProfiles = (term, options) => {
return this.doFetch(
@@ -567,6 +567,13 @@ export default class Client {
);
};
+ autocompleteChannels = async (teamId, term) => {
+ return this.doFetch(
+ `${this.getChannelsRoute(teamId)}/autocomplete?term=${encodeURIComponent(term)}`,
+ {method: 'get'}
+ );
+ }
+
// Post routes
createPost = async (teamId, post) => {
return this.doFetch(
diff --git a/service/constants/channels.js b/service/constants/channels.js
index d403af5d0..a9edefa64 100644
--- a/service/constants/channels.js
+++ b/service/constants/channels.js
@@ -60,6 +60,10 @@ const ChannelTypes = keyMirror({
REMOVE_CHANNEL_MEMBER_SUCCESS: null,
REMOVE_CHANNEL_MEMBER_FAILURE: null,
+ AUTOCOMPLETE_CHANNELS_REQUEST: null,
+ AUTOCOMPLETE_CHANNELS_SUCCESS: null,
+ AUTOCOMPLETE_CHANNELS_FAILURE: null,
+
SELECT_CHANNEL: null,
LEAVE_CHANNEL: null,
RECEIVED_CHANNEL: null,
@@ -71,6 +75,7 @@ const ChannelTypes = keyMirror({
RECEIVED_CHANNEL_PROPS: null,
RECEIVED_CHANNEL_DELETED: null,
RECEIVED_LAST_VIEWED: null,
+ RECEIVED_AUTOCOMPLETE_CHANNELS: null,
UPDATE_CHANNEL_HEADER: null,
UPDATE_CHANNEL_PURPOSE: null
});
diff --git a/service/reducers/entities/channels.js b/service/reducers/entities/channels.js
index 259841502..49b2c3aa5 100644
--- a/service/reducers/entities/channels.js
+++ b/service/reducers/entities/channels.js
@@ -139,6 +139,15 @@ function stats(state = {}, action) {
}
}
+function autocompleteChannels(state = [], action) {
+ switch (action.type) {
+ case ChannelTypes.RECEIVED_AUTOCOMPLETE_CHANNELS:
+ return action.data;
+ default:
+ return state;
+ }
+}
+
export default combineReducers({
// the current selected channel
@@ -151,5 +160,8 @@ export default combineReducers({
myMembers,
// object where every key is the channel id and has an object with the channel stats
- stats
+ stats,
+
+ // array containing channel objects that have been matched to the current channel mention term
+ autocompleteChannels
});
diff --git a/service/reducers/requests/channels.js b/service/reducers/requests/channels.js
index c2d4d5ba3..0bb9110fb 100644
--- a/service/reducers/requests/channels.js
+++ b/service/reducers/requests/channels.js
@@ -146,6 +146,16 @@ function removeChannelMember(state = initialRequestState(), action) {
);
}
+function autocompleteChannels(state = initialRequestState(), action) {
+ return handleRequest(
+ ChannelTypes.AUTOCOMPLETE_CHANNELS_REQUEST,
+ ChannelTypes.AUTOCOMPLETE_CHANNELS_SUCCESS,
+ ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE,
+ state,
+ action
+ );
+}
+
export default combineReducers({
getChannel,
getChannels,
@@ -160,5 +170,6 @@ export default combineReducers({
getMoreChannels,
getChannelStats,
addChannelMember,
- removeChannelMember
+ removeChannelMember,
+ autocompleteChannels
});
diff --git a/service/selectors/entities/channels.js b/service/selectors/entities/channels.js
index 7d0c07d2d..ae9633ba8 100644
--- a/service/selectors/entities/channels.js
+++ b/service/selectors/entities/channels.js
@@ -22,6 +22,10 @@ export function getChannelMemberships(state) {
return state.entities.channels.myMembers;
}
+export function getAutocompleteChannels(state) {
+ return state.entities.channels.autocompleteChannels;
+}
+
export const getCurrentChannel = createSelector(
getAllChannels,
getCurrentChannelId,
@@ -124,3 +128,23 @@ export const getUnreads = createSelector(
return {messageCount, mentionCount};
}
);
+
+export const getAutocompleteChannelWithSections = createSelector(
+ getChannelMemberships,
+ getAutocompleteChannels,
+ (myMembers, autocompleteChannels) => {
+ const channels = {
+ myChannels: [],
+ otherChannels: []
+ };
+ autocompleteChannels.forEach((c) => {
+ if (myMembers[c.id]) {
+ channels.myChannels.push(c);
+ } else {
+ channels.otherChannels.push(c);
+ }
+ });
+
+ return channels;
+ }
+);
diff --git a/test/service/actions/channels.test.js b/test/service/actions/channels.test.js
index 42d7346dd..11405a076 100644
--- a/test/service/actions/channels.test.js
+++ b/test/service/actions/channels.test.js
@@ -426,4 +426,24 @@ describe('Actions.Channels', () => {
assert.ok(channel);
assert.deepEqual(channel.purpose, purpose);
});
+
+ it('autocompleteChannels', async () => {
+ await Actions.autocompleteChannels(
+ TestHelper.basicTeam.id,
+ ''
+ )(store.dispatch, store.getState);
+
+ const autocompleteRequest = store.getState().requests.channels.autocompleteChannels;
+ const data = store.getState().entities.channels.autocompleteChannels;
+
+ if (autocompleteRequest.status === RequestStatus.FAILURE) {
+ throw new Error(JSON.stringify(autocompleteRequest.error));
+ }
+
+ assert.ok(data.length);
+
+ const channel = data.find((c) => c.id === TestHelper.basicChannel.id);
+
+ assert.ok(channel);
+ });
});