[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:
parent
c38eb2aaab
commit
c1eff885e7
13 changed files with 145 additions and 44 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/>}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in a new issue