PLT-5541 Autocomplete mentions in post textbox (#266)

* WIP channel autocomplete

* Channel mentions

* Feedback Review
This commit is contained in:
Chris Duarte 2017-02-20 13:19:11 -08:00 committed by enahum
parent 42ef890415
commit 4f2d769766
10 changed files with 418 additions and 4 deletions

View file

@ -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 (
<View style={style.sectionWrapper}>
<View style={style.section}>
<FormattedText
id={localization[sectionId].id}
defaultMessage={localization[sectionId].defaultMessage}
style={style.sectionText}
/>
</View>
</View>
);
}
renderRow = (data) => {
const style = getStyleFromTheme(this.props.theme);
return (
<TouchableOpacity
onPress={() => this.completeMention(data.name)}
style={style.row}
>
<Text style={style.rowDisplayName}>{data.display_name}</Text>
<Text style={style.rowName}>{` (~${data.name})`}</Text>
</TouchableOpacity>
);
}
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 (
<View style={style.loading}>
<FormattedText
id='analytics.chart.loading": "Loading...'
defaultMessage='Loading...'
style={style.sectionText}
/>
</View>
);
}
return (
<ListView
keyboardShouldPersistTaps='always'
style={style.listView}
enableEmptySections={true}
dataSource={this.state.dataSource}
renderSectionHeader={this.renderSectionHeader}
renderRow={this.renderRow}
pageSize={10}
initialListSize={10}
/>
);
}
}

View file

@ -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);

View file

@ -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 {
<View>
<View style={style.container}>
<AtMention cursorPosition={this.state.cursorPosition}/>
<ChannelMention cursorPosition={this.state.cursorPosition}/>
</View>
</View>
);

View file

@ -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
};

View file

@ -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(

View file

@ -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
});

View file

@ -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
});

View file

@ -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
});

View file

@ -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;
}
);

View file

@ -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);
});
});