diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 5b82aeb9b..440a6f202 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -12,6 +12,8 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import AtMention from './at_mention/'; import ChannelMention from './channel_mention/'; import EmojiSuggestion from './emoji_suggestion/'; +import SlashSuggestion from './slash_suggestion/'; +import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion/'; const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { @@ -92,13 +94,14 @@ const Autocomplete = ({ const [showingAtMention, setShowingAtMention] = useState(false); const [showingChannelMention, setShowingChannelMention] = useState(false); const [showingEmoji, setShowingEmoji] = useState(false); + const [showingCommand, setShowingCommand] = useState(false); + const [showingAppCommand, setShowingAppCommand] = useState(false); - // const [showingCommand, setShowingCommand] = useState(false); - // const [showingAppCommand, setShowingAppCommand] = useState(false); // const [showingDate, setShowingDate] = useState(false); - const hasElements = showingChannelMention || showingEmoji || showingAtMention; // || showingCommand || showingAppCommand || showingDate; - const appsTakeOver = false; // showingAppCommand; + const hasElements = showingChannelMention || showingEmoji || showingAtMention || showingCommand || showingAppCommand; // || showingDate; + const appsTakeOver = showingAppCommand; + const showCommands = !(showingChannelMention || showingEmoji || showingAtMention); const maxListHeight = useMemo(() => { if (maxHeightOverride) { @@ -151,15 +154,17 @@ const Autocomplete = ({ testID='autocomplete' style={containerStyles} > - {/* {isAppsEnabled && ( + {isAppsEnabled && ( - )} */} + )} {(!appsTakeOver || !isAppsEnabled) && (<> } - {/* - {(isSearch && enableDateSuggestion) && + } + {/* {(isSearch && enableDateSuggestion) && { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_suggestion', defaultMessage: 'No matching suggestions.', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: '', + IconData: COMMAND_SUGGESTION_ERROR, + Description: '', }]; }; getErrorSuggestion = (parsed: ParsedCommand): AutocompleteSuggestion[] => { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.errors.parser_error', defaultMessage: 'Parsing error', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: parsed.error, + IconData: COMMAND_SUGGESTION_ERROR, + Description: parsed.error, }]; }; @@ -940,22 +940,21 @@ export class AppCommandParser { // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { - if (choice.complete && choice.complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { return choice as AutocompleteSuggestion; } let goBackSpace = 0; - if (choice.complete === '') { + if (choice.Complete === '') { goBackSpace = 1; } let complete = parsed.command.substring(0, parsed.incompleteStart - goBackSpace); - complete += choice.complete === undefined ? choice.suggestion : choice.complete; - choice.hint = choice.hint || ''; + complete += choice.Complete === undefined ? choice.Suggestion : choice.Complete; complete = complete.substring(1); return { ...choice, - complete, + Complete: complete, }; }; @@ -1097,11 +1096,11 @@ export class AppCommandParser { bindings.forEach((b) => { if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { result.push({ - complete: b.label, - suggestion: b.label, - description: b.description || '', - hint: b.hint || '', - iconData: b.icon || '', + Complete: b.label, + Suggestion: b.label, + Description: b.description || '', + Hint: b.hint || '', + IconData: b.icon || '', }); } }); @@ -1187,11 +1186,11 @@ export class AppCommandParser { if (applicable) { return applicable.map((f) => { return { - complete: prefix + (f.label || f.name), - suggestion: '--' + (f.label || f.name), - description: f.description || '', - hint: f.hint || '', - iconData: parsed.binding?.icon || '', + Complete: prefix + (f.label || f.name), + Suggestion: '--' + (f.label || f.name), + Description: f.description || '', + Hint: f.hint || '', + IconData: parsed.binding?.icon || '', }; }); } @@ -1232,11 +1231,11 @@ export class AppCommandParser { const fieldName = parsed.field.modal_label || parsed.field.label || parsed.field.name; return [{ - complete, - suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, - description: f.description || '', - hint: '', - iconData: parsed.binding?.icon || '', + Complete: complete, + Suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, + Description: f.description || '', + Hint: '', + IconData: parsed.binding?.icon || '', }]; }; @@ -1246,14 +1245,14 @@ export class AppCommandParser { const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); if (!opts?.length) { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_static', defaultMessage: 'No matching options.', }), - description: '', - iconData: COMMAND_SUGGESTION_ERROR, + Description: '', + IconData: COMMAND_SUGGESTION_ERROR, }]; } return opts.map((opt) => { @@ -1264,11 +1263,11 @@ export class AppCommandParser { complete = '`' + complete + '`'; } return { - complete, - suggestion: opt.label, - hint: f.hint || '', - description: f.description || '', - iconData: opt.icon_data || parsed.binding?.icon || '', + Complete: complete, + Suggestion: opt.label, + Hint: f.hint || '', + Description: f.description || '', + IconData: opt.icon_data || parsed.binding?.icon || '', }; }); }; @@ -1331,14 +1330,14 @@ export class AppCommandParser { items = items?.filter(filterEmptyOptions); if (!items?.length) { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_static', defaultMessage: 'No matching options.', }), - iconData: '', - description: this.intl.formatMessage({ + IconData: '', + Description: this.intl.formatMessage({ id: 'apps.suggestion.no_dynamic', defaultMessage: 'No data was returned for dynamic suggestions', }), @@ -1353,11 +1352,11 @@ export class AppCommandParser { complete = '`' + complete + '`'; } return ({ - complete, - description: s.label || s.value, - suggestion: s.value, - hint: '', - iconData: s.icon_data || parsed.binding?.icon || '', + Complete: complete, + Description: s.label || s.value, + Suggestion: s.value, + Hint: '', + IconData: s.icon_data || parsed.binding?.icon || '', }); }); }; @@ -1370,14 +1369,14 @@ export class AppCommandParser { error: message, }); return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.dynamic.error', defaultMessage: 'Dynamic select error', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: errMsg, + IconData: COMMAND_SUGGESTION_ERROR, + Description: errMsg, }]; }; @@ -1405,20 +1404,20 @@ export class AppCommandParser { if ('true'.startsWith(parsed.incomplete)) { suggestions.push({ - complete: 'true', - suggestion: 'true', - description: parsed.field?.description || '', - hint: parsed.field?.hint || '', - iconData: parsed.binding?.icon || '', + Complete: 'true', + Suggestion: 'true', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', }); } if ('false'.startsWith(parsed.incomplete)) { suggestions.push({ - complete: 'false', - suggestion: 'false', - description: parsed.field?.description || '', - hint: parsed.field?.hint || '', - iconData: parsed.binding?.icon || '', + Complete: 'false', + Suggestion: 'false', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', }); } return suggestions; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts index a11f71082..a1577c5ab 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts @@ -13,11 +13,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin const res = await searchUsers(serverUrl, lastWord.substring(1), channelID); const users = await getUserSuggestions(res.users); users.forEach((u) => { - let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.complete : u.complete; + let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete; if (delimiter) { complete = delimiter + complete; } - u.complete = complete; + u.Complete = complete; }); return users; } @@ -26,11 +26,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin const res = await searchChannels(serverUrl, lastWord.substring(1)); const channels = await getChannelSuggestions(res.channels); channels.forEach((c) => { - let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.complete : c.complete; + let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete; if (delimiter) { complete = delimiter + complete; } - c.complete = complete; + c.Complete = complete; }); return channels; } @@ -40,11 +40,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin export async function getUserSuggestions(usersAutocomplete?: {users: UserProfile[]; out_of_channel?: UserProfile[]}): Promise { const notFoundSuggestions = [{ - complete: '', - suggestion: '', - description: 'No user found', - hint: '', - iconData: '', + Complete: '', + Suggestion: '', + Description: 'No user found', + Hint: '', + IconData: '', }]; if (!usersAutocomplete) { return notFoundSuggestions; @@ -67,11 +67,11 @@ export async function getUserSuggestions(usersAutocomplete?: {users: UserProfile export async function getChannelSuggestions(channels?: Channel[]): Promise { const notFoundSuggestion = [{ - complete: '', - suggestion: '', - description: 'No channel found', - hint: '', - iconData: '', + Complete: '', + Suggestion: '', + Description: 'No channel found', + Hint: '', + IconData: '', }]; if (!channels) { return notFoundSuggestion; @@ -82,11 +82,11 @@ export async function getChannelSuggestions(channels?: Channel[]): Promise { return { - complete: '~' + c.name, - suggestion: '', - description: '', - hint: '', - iconData: '', + Complete: '~' + c.name, + Suggestion: '', + Description: '', + Hint: '', + IconData: '', type: COMMAND_SUGGESTION_CHANNEL, item: c, }; @@ -97,11 +97,11 @@ export async function getChannelSuggestions(channels?: Channel[]): Promise void; - onResultCountChange: (count: number) => void; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; value: string; nestedScrollEnabled?: boolean; rootId?: string; channelId: string; - appsEnabled: boolean; + isAppsEnabled: boolean; }; -const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => (item.suggestion || '') + item.type + item.item; +const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item; const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -47,28 +48,40 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { }; }); +const emptySuggestonList: AutocompleteSuggestion[] = []; + const AppSlashSuggestion = ({ channelId, currentTeamId, rootId, value = '', - appsEnabled, + isAppsEnabled, maxListHeight, nestedScrollEnabled, - onChangeText, - onResultCountChange, + updateValue, + onShowingChange, }: Props) => { const intl = useIntl(); const theme = useTheme(); const serverUrl = useServerUrl(); const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); - const [dataSource, setDataSource] = useState([]); - const active = appsEnabled && Boolean(dataSource.length); + const [dataSource, setDataSource] = useState(emptySuggestonList); + const active = isAppsEnabled && Boolean(dataSource.length); const style = getStyleFromTheme(theme); + const mounted = useRef(false); + + const fetchAndShowAppCommandSuggestions = useMemo(() => debounce(async (pretext: string, cId: string, tId = '', rId?: string) => { + appCommandParser.current.setChannelContext(cId, tId, rId); + const suggestions = await appCommandParser.current.getSuggestions(pretext); + if (!mounted.current) { + return; + } + updateSuggestions(suggestions); + }), []); const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => { setDataSource(matches); - onResultCountChange(matches.length); + onShowingChange(Boolean(matches.length)); }; const completeSuggestion = (command: string) => { @@ -81,13 +94,13 @@ const AppSlashSuggestion = ({ completedDraft = `//${command} `; } - onChangeText(completedDraft); + updateValue(completedDraft); if (Platform.OS === 'ios') { // This is the second part of the hack were we replace the double / with just one // after the auto correct vanished setTimeout(() => { - onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } }; @@ -113,7 +126,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -124,19 +137,19 @@ const AppSlashSuggestion = ({ return ( ); default: return ( ); } @@ -147,31 +160,35 @@ const AppSlashSuggestion = ({ return appCommandParser.current.isAppCommand(pretext); }; - const fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => { - appCommandParser.current.setChannelContext(channelID, teamID, rootID); - const suggestions = await appCommandParser.current.getSuggestions(pretext); - updateSuggestions(suggestions); - }; - useEffect(() => { if (value[0] !== '/') { - setDataSource([]); - onResultCountChange(0); + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); return; } if (value.indexOf(' ') === -1) { - setDataSource([]); + // Let slash command suggestions handle base commands. + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); return; } if (!isAppCommand(value, channelId, currentTeamId, rootId)) { - setDataSource([]); + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); return; } fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId); }, [value]); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + if (!active) { // If we are not in an active state return null so nothing is rendered // other components are not blocked. diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index e54a3296a..80d20838b 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -47,30 +47,29 @@ const filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSug return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm); }); - return data.map((item) => { + return data.map((command) => { return { - complete: item.trigger, - suggestion: '/' + item.trigger, - hint: item.auto_complete_hint, - description: item.auto_complete_desc, - iconData: item.icon_url || item.autocomplete_icon_data || '', + Complete: command.trigger, + Suggestion: '/' + command.trigger, + Hint: command.auto_complete_hint, + Description: command.auto_complete_desc, + IconData: command.icon_url || command.autocomplete_icon_data || '', }; }); }; -const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.suggestion || ''; +const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion; type Props = { currentTeamId: string; - commands: Command[]; maxListHeight?: number; - onChangeText: (text: string) => void; - onResultCountChange: (count: number) => void; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; value: string; nestedScrollEnabled?: boolean; rootId?: string; channelId: string; - appsEnabled: boolean; + isAppsEnabled: boolean; }; const emptyCommandList: Command[] = []; @@ -80,11 +79,11 @@ const SlashSuggestion = ({ channelId, currentTeamId, rootId, - onResultCountChange, - appsEnabled, + onShowingChange, + isAppsEnabled, maxListHeight, nestedScrollEnabled, - onChangeText, + updateValue, value = '', }: Props) => { const intl = useIntl(); @@ -92,6 +91,7 @@ const SlashSuggestion = ({ const style = getStyleFromTheme(theme); const serverUrl = useServerUrl(); const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const mounted = useRef(false); const [dataSource, setDataSource] = useState(emptySuggestionList); const [commands, setCommands] = useState(); @@ -100,12 +100,15 @@ const SlashSuggestion = ({ const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => { setDataSource(matches); - onResultCountChange(matches.length); - }, [onResultCountChange]); + onShowingChange(Boolean(matches.length)); + }, [onShowingChange]); const runFetch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string, cId: string, rId?: string) => { try { const res = await fetchSuggestions(sUrl, term, tId, cId, rId); + if (!mounted.current) { + return; + } if (res.error) { updateSuggestions(emptySuggestionList); } else if (res.suggestions.length === 0) { @@ -127,7 +130,7 @@ const SlashSuggestion = ({ const showBaseCommands = (text: string) => { let matches: AutocompleteSuggestion[] = []; - if (appsEnabled) { + if (isAppsEnabled) { const appCommands = getAppBaseCommandSuggestions(text); matches = matches.concat(appCommands); } @@ -135,10 +138,10 @@ const SlashSuggestion = ({ matches = matches.concat(filterCommands(text.substring(1), commands!)); matches.sort((match1, match2) => { - if (match1.suggestion === match2.suggestion) { + if (match1.Suggestion === match2.Suggestion) { return 0; } - return match1.suggestion > match2.suggestion ? 1 : -1; + return match1.Suggestion > match2.Suggestion ? 1 : -1; }); updateSuggestions(matches); @@ -154,30 +157,31 @@ const SlashSuggestion = ({ completedDraft = `//${command} `; } - onChangeText(completedDraft); + updateValue(completedDraft); if (Platform.OS === 'ios') { // This is the second part of the hack were we replace the double / with just one // after the auto correct vanished setTimeout(() => { - onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } - }, [onChangeText, serverUrl]); + }, [updateValue, serverUrl]); const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => ( ), [completeSuggestion]); useEffect(() => { if (value[0] !== '/') { + runFetch.cancel(); updateSuggestions(emptySuggestionList); return; } @@ -194,6 +198,7 @@ const SlashSuggestion = ({ } if (value.indexOf(' ') === -1) { + runFetch.cancel(); showBaseCommands(value); return; } @@ -201,6 +206,13 @@ const SlashSuggestion = ({ runFetch(serverUrl, value, currentTeamId, channelId, rootId); }, [value, commands]); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + if (!active) { // If we are not in an active state return null so nothing is rendered // other components are not blocked. diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index c5aaafaca..bf0c35132 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -59,11 +59,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { type Props = { complete: string; - description?: string; - hint?: string; + description: string; + hint: string; onPress: (complete: string) => void; - suggestion?: string; - icon?: string; + suggestion: string; + icon: string; } const SlashSuggestionItem = ({ @@ -112,15 +112,16 @@ const SlashSuggestionItem = ({ source={bangIcon} /> ); - } else if (icon && icon.startsWith('http')) { + } else if (icon.startsWith('http')) { image = ( ); - } else if (icon && icon.startsWith('data:')) { + } else if (icon.startsWith('data:')) { if (icon.startsWith('data:image/svg+xml')) { + // TODO: What base64 library should we use? Security implications on doing things like this? const xml = ''; // base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); image = (