PLT-5541 Autocomplete mentions in post textbox (#266)
* WIP channel autocomplete * Channel mentions * Feedback Review
This commit is contained in:
parent
42ef890415
commit
4f2d769766
10 changed files with 418 additions and 4 deletions
270
app/components/autocomplete/channel_mention/channel_mention.js
Normal file
270
app/components/autocomplete/channel_mention/channel_mention.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/components/autocomplete/channel_mention/index.js
Normal file
36
app/components/autocomplete/channel_mention/index.js
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue