[MM-25749] Implement non-cached autocomplete for mobile (#4579)

* Implement non-cached autocomplete for mobile

* Add partial caching support

* Fix whitespace

* Implement suggestion fetching using actions
This commit is contained in:
Shota Gvinepadze 2020-07-21 00:32:47 +04:00 committed by GitHub
parent c38eb2aaab
commit c1eff885e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 145 additions and 44 deletions

View file

@ -5,8 +5,8 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {getAutocompleteCommands} from '@mm-redux/actions/integrations';
import {getAutocompleteCommandsList} from '@mm-redux/selectors/entities/integrations';
import {getAutocompleteCommands, getCommandAutocompleteSuggestions} from '@mm-redux/actions/integrations';
import {getAutocompleteCommandsList, getCommandAutocompleteSuggestionsList} from '@mm-redux/selectors/entities/integrations';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {isLandscape} from 'app/selectors/device';
@ -32,6 +32,7 @@ function mapStateToProps(state) {
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state),
isLandscape: isLandscape(state),
suggestions: getCommandAutocompleteSuggestionsList(state),
};
}
@ -39,8 +40,9 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getAutocompleteCommands,
getCommandAutocompleteSuggestions,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion);
export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion);

View file

@ -12,14 +12,16 @@ import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divide
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import SlashSuggestionItem from './slash_suggestion_item';
import {Client4} from '@mm-redux/client';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/;
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
export default class SlashSuggestion extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getAutocompleteCommands: PropTypes.func.isRequired,
getCommandAutocompleteSuggestions: PropTypes.func.isRequired,
}).isRequired,
currentTeamId: PropTypes.string.isRequired,
commands: PropTypes.array,
@ -31,6 +33,9 @@ export default class SlashSuggestion extends PureComponent {
value: PropTypes.string,
isLandscape: PropTypes.bool.isRequired,
nestedScrollEnabled: PropTypes.bool,
suggestions: PropTypes.array,
rootId: PropTypes.string,
channelId: PropTypes.string,
};
static defaultProps = {
@ -40,13 +45,13 @@ export default class SlashSuggestion extends PureComponent {
state = {
active: false,
suggestionComplete: false,
dataSource: [],
lastCommandRequest: 0,
};
componentWillReceiveProps(nextProps) {
if (nextProps.isSearch) {
if ((nextProps.value === this.props.value && nextProps.suggestions === this.props.suggestions) ||
nextProps.isSearch || nextProps.value.startsWith('//') || !nextProps.channelId) {
return;
}
@ -55,49 +60,73 @@ export default class SlashSuggestion extends PureComponent {
commands: nextCommands,
currentTeamId: nextTeamId,
value: nextValue,
suggestions: nextSuggestions,
} = nextProps;
if (currentTeamId !== nextTeamId) {
this.setState({
lastCommandRequest: 0,
});
}
const match = nextValue.match(SLASH_REGEX);
if (!match || this.state.suggestionComplete) {
if (nextValue[0] !== '/') {
this.setState({
active: false,
matchTerm: null,
suggestionComplete: false,
});
this.props.onResultCountChange(0);
return;
}
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
if (nextValue.indexOf(' ') === -1) { // return suggestions for a top level cached commands
if (currentTeamId !== nextTeamId) {
this.setState({
lastCommandRequest: 0,
});
}
if ((!nextCommands.length || dataIsStale)) {
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
if ((!nextCommands.length || dataIsStale)) {
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
this.setState({
lastCommandRequest: Date.now(),
});
}
const matches = this.filterSlashSuggestions(nextValue.substring(1), nextCommands);
this.updateSuggestions(matches);
} else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
if (nextProps.suggestions === this.props.suggestions) {
const args = {
channel_id: this.props.channelId,
...(this.props.rootId && {root_id: this.props.rootId, parent_id: this.props.rootId}),
};
this.props.actions.getCommandAutocompleteSuggestions(nextProps.value, nextProps.currentTeamId, args);
} else {
const matches = [];
nextSuggestions.forEach((sug) => {
if (!this.contains(matches, '/' + sug.Complete)) {
matches.push({
Complete: sug.Complete,
Suggestion: sug.Suggestion,
Hint: sug.Hint,
Description: sug.Description,
});
}
});
this.updateSuggestions(matches);
}
} else {
this.setState({
lastCommandRequest: Date.now(),
active: false,
});
}
}
const matchTerm = match[2];
const data = this.filterSlashSuggestions(matchTerm, nextCommands);
updateSuggestions = (matches) => {
this.setState({
active: data.length,
dataSource: data,
active: matches.length,
dataSource: matches,
});
this.props.onResultCountChange(data.length);
this.props.onResultCountChange(matches.length);
}
filterSlashSuggestions = (matchTerm, commands) => {
return commands.filter((command) => {
const data = commands.filter((command) => {
if (!command.auto_complete) {
return false;
} else if (!matchTerm) {
@ -106,6 +135,18 @@ export default class SlashSuggestion extends PureComponent {
return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm);
});
return data.map((item) => {
return {
Complete: item.trigger,
Suggestion: '/' + item.trigger,
Hint: item.auto_complete_hint,
Description: item.auto_complete_desc,
};
});
}
contains = (matches, complete) => {
return matches.findIndex((match) => match.complete === complete) !== -1;
}
completeSuggestion = (command) => {
@ -128,21 +169,23 @@ export default class SlashSuggestion extends PureComponent {
});
}
this.setState({
active: false,
suggestionComplete: true,
});
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
this.setState({
active: false,
});
}
};
keyExtractor = (item) => item.id || item.trigger;
keyExtractor = (item) => item.id || item.Suggestion;
renderItem = ({item}) => (
<SlashSuggestionItem
description={item.auto_complete_desc}
hint={item.auto_complete_hint}
description={item.Description}
hint={item.Hint}
onPress={this.completeSuggestion}
theme={this.props.theme}
trigger={item.trigger}
suggestion={item.Suggestion}
complete={item.Complete}
isLandscape={this.props.isLandscape}
/>
)

View file

@ -15,13 +15,14 @@ export default class SlashSuggestionItem extends PureComponent {
hint: PropTypes.string,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
trigger: PropTypes.string,
suggestion: PropTypes.string,
complete: PropTypes.string,
isLandscape: PropTypes.bool.isRequired,
};
completeSuggestion = () => {
const {onPress, trigger} = this.props;
onPress(trigger);
const {onPress, complete} = this.props;
onPress(complete);
};
render() {
@ -29,7 +30,7 @@ export default class SlashSuggestionItem extends PureComponent {
description,
hint,
theme,
trigger,
suggestion,
isLandscape,
} = this.props;
@ -41,7 +42,7 @@ export default class SlashSuggestionItem extends PureComponent {
style={[style.row, padding(isLandscape)]}
type={'opacity'}
>
<Text style={style.suggestionName}>{`/${trigger} ${hint}`}</Text>
<Text style={style.suggestionName}>{`${suggestion} ${hint}`}</Text>
<Text style={style.suggestionDescription}>{description}</Text>
</TouchableWithFeedback>
);

View file

@ -679,6 +679,8 @@ export default class PostDraft extends PureComponent {
maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={this.handleInputQuickAction}
valueEvent={valueEvent}
rootId={rootId}
channelId={channelId}
/>
}
<View

View file

@ -13,6 +13,8 @@ export default keyMirror({
RECEIVED_CUSTOM_TEAM_COMMANDS: null,
RECEIVED_COMMAND: null,
RECEIVED_COMMANDS: null,
RECEIVED_COMMAND_SUGGESTIONS: null,
RECEIVED_COMMAND_SUGGESTIONS_FAILURE: null,
RECEIVED_COMMAND_TOKEN: null,
DELETED_COMMAND: null,
RECEIVED_OAUTH_APP: null,

View file

@ -174,6 +174,19 @@ export function getAutocompleteCommands(teamId: string, page = 0, perPage: numbe
});
}
export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: any): ActionFunc {
return bindClientFunc({
clientFunc: Client4.getCommandAutocompleteSuggestionsList,
onSuccess: [IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS],
onFailure: IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS_FAILURE,
params: [
userInput,
teamId,
commandArgs,
],
});
}
export function getCustomTeamCommands(teamId: string): ActionFunc {
return bindClientFunc({
clientFunc: Client4.getCustomTeamCommands,

View file

@ -2817,6 +2817,13 @@ export default class Client4 {
);
};
getCommandAutocompleteSuggestionsList = (userInput: string, teamId: string, commandArgs: {}) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({...commandArgs, user_input: userInput})}`,
{method: 'get'},
);
};
// Groups
linkGroupSyncable = async (groupID: string, syncableID: string, syncableType: string, patch: SyncablePatch) => {

View file

@ -4,7 +4,7 @@
import {combineReducers} from 'redux';
import {IntegrationTypes, ChannelTypes} from '@mm-redux/action_types';
import {GenericAction} from '@mm-redux/types/actions';
import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp} from '@mm-redux/types/integrations';
import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, AutocompleteSuggestion} from '@mm-redux/types/integrations';
import {Dictionary, IDMappedObjects} from '@mm-redux/types/utilities';
function incomingHooks(state: IDMappedObjects<IncomingWebhook> = {}, action: GenericAction) {
@ -159,6 +159,17 @@ function systemCommands(state: IDMappedObjects<Command> = {}, action: GenericAct
}
}
function commandAutocompleteSuggestions(state: Array<AutocompleteSuggestion> = [], action: GenericAction) {
switch (action.type) {
case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS:
return action.data;
case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS_FAILURE:
return [];
default:
return state;
}
}
function oauthApps(state: IDMappedObjects<OAuthApp> = {}, action: GenericAction) {
switch (action.type) {
case IntegrationTypes.RECEIVED_OAUTH_APPS: {
@ -224,4 +235,7 @@ export default combineReducers({
// data for an interactive dialog to display
dialog,
// object represents slash command autocomplete suggestions
commandAutocompleteSuggestions,
});

View file

@ -26,6 +26,10 @@ export function getSystemCommands(state: types.store.GlobalState) {
return state.entities.integrations.systemCommands;
}
export function getCommandAutocompleteSuggestionsList(state: types.store.GlobalState) {
return state.entities.integrations.commandAutocompleteSuggestions;
}
/**
* get outgoing hooks in current team
*/

View file

@ -52,6 +52,15 @@ export type Command = {
'description': string;
'url': string;
};
// AutocompleteSuggestion represents a single suggestion downloaded from the server.
export type AutocompleteSuggestion = {
Complete: string;
Suggestion: string;
Hint: string;
Description: string;
IconData: string;
};
export type OAuthApp = {
'id': string;
'creator_id': string;
@ -71,6 +80,7 @@ export type IntegrationsState = {
oauthApps: IDMappedObjects<OAuthApp>;
systemCommands: IDMappedObjects<Command>;
commands: IDMappedObjects<Command>;
commandAutocompleteSuggestions: Array<AutocompleteSuggestion>;
};
export type DialogSubmission = {
url: string;

View file

@ -71,6 +71,7 @@ export default class ChannelIOS extends ChannelBase {
onChangeText={this.handleAutoComplete}
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
channelId={currentChannelId}
/>
</View>
{LocalConfig.EnableMobileClientUpgrade && <ClientUpgradeListener/>}

View file

@ -52,6 +52,7 @@ exports[`thread should match snapshot, has root post 1`] = `
nativeID="threadAccessoriesContainer"
>
<Connect(Autocomplete)
channelId="channel_id"
cursorPositionEvent="onThreadTextBoxCursorChange"
maxHeight={200}
onChangeText={[Function]}

View file

@ -65,6 +65,7 @@ export default class ThreadIOS extends ThreadBase {
cursorPositionEvent={THREAD_POST_TEXTBOX_CURSOR_CHANGE}
valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE}
rootId={rootId}
channelId={channelId}
/>
</View>
</>