From 4f2d769766fde2660b23a2b99f85993867dfcc1c Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Mon, 20 Feb 2017 13:19:11 -0800 Subject: [PATCH] PLT-5541 Autocomplete mentions in post textbox (#266) * WIP channel autocomplete * Channel mentions * Feedback Review --- .../channel_mention/channel_mention.js | 270 ++++++++++++++++++ .../autocomplete/channel_mention/index.js | 36 +++ app/components/autocomplete/index.js | 2 + service/actions/channels.js | 29 +- service/client/client.js | 9 +- service/constants/channels.js | 5 + service/reducers/entities/channels.js | 14 +- service/reducers/requests/channels.js | 13 +- service/selectors/entities/channels.js | 24 ++ test/service/actions/channels.test.js | 20 ++ 10 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 app/components/autocomplete/channel_mention/channel_mention.js create mode 100644 app/components/autocomplete/channel_mention/index.js 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); + }); });