diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index 94a6656cd1..4f69940b7a 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -9,6 +9,7 @@ import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/rem import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user'; import {AppCallResponseTypes, AppCallTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; import DatabaseManager from '@database/manager'; +import IntegrationsManager from '@init/integrations_manager'; import {getChannelById, queryChannelsByNames} from '@queries/servers/channel'; import {getCurrentTeamId} from '@queries/servers/system'; import {getUserById, queryUsersByUsername} from '@queries/servers/user'; @@ -48,26 +49,6 @@ interface Intl { formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; } -// TODO: Implemnet app bindings -const getCommandBindings = () => { - return []; -}; -const getRHSCommandBindings = () => { - return []; -}; -const getAppRHSCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const getAppCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const setAppRHSCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const setAppCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; - // Common dependencies with Webapp. Due to the big change of removing redux, we may have to rethink how to deal with this. const getExecuteSuggestion = (parsed: ParsedCommand) => null; // eslint-disable-line @typescript-eslint/no-unused-vars export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; @@ -961,10 +942,11 @@ export class AppCommandParser { // getCommandBindings returns the commands in the redux store. // They are grouped by app id since each app has one base command private getCommandBindings = (): AppBinding[] => { + const manager = IntegrationsManager.getManager(this.serverUrl); if (this.rootPostID) { - return getRHSCommandBindings(); + return manager.getRHSCommandBindings(); } - return getCommandBindings(); + return manager.getCommandBindings(); }; // getChannel gets the channel in which the user is typing the command @@ -1067,9 +1049,10 @@ export class AppCommandParser { }; public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + const manager = IntegrationsManager.getManager(this.serverUrl); const rootID = this.rootPostID || ''; const key = `${this.channelID}-${rootID}-${location}`; - const form = this.rootPostID ? getAppRHSCommandForm(key) : getAppCommandForm(key); + const form = this.rootPostID ? manager.getAppRHSCommandForm(key) : manager.getAppCommandForm(key); if (form) { return {form}; } @@ -1077,9 +1060,9 @@ export class AppCommandParser { const fetched = await this.fetchForm(binding); if (fetched?.form) { if (this.rootPostID) { - setAppRHSCommandForm(key, fetched.form); + manager.setAppRHSCommandForm(key, fetched.form); } else { - setAppCommandForm(key, fetched.form); + manager.setAppCommandForm(key, fetched.form); } } return fetched; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx index eb755caac1..920d846f48 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {debounce} from 'lodash'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import { FlatList, @@ -70,6 +70,8 @@ const AppSlashSuggestion = ({ const style = getStyleFromTheme(theme); const mounted = useRef(false); + const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]); + 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); @@ -84,7 +86,7 @@ const AppSlashSuggestion = ({ onShowingChange(Boolean(matches.length)); }; - const completeSuggestion = (command: string) => { + const completeSuggestion = useCallback((command: string) => { analytics.get(serverUrl)?.trackCommand('complete_suggestion', `/${command} `); // We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it @@ -103,21 +105,15 @@ const AppSlashSuggestion = ({ updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } - }; + }, [serverUrl, updateValue]); - const completeUserSuggestion = (base: string): (username: string) => void => { + const completeIgnoringSuggestion = useCallback((base: string): (toIgnore: string) => void => { return () => { completeSuggestion(base); }; - }; + }, [completeSuggestion]); - const completeChannelMention = (base: string): (channelName: string) => void => { - return () => { - completeSuggestion(base); - }; - }; - - const renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => { + const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => { switch (item.type) { case COMMAND_SUGGESTION_USER: if (!item.item) { @@ -126,7 +122,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -137,7 +133,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -153,7 +149,7 @@ const AppSlashSuggestion = ({ /> ); } - }; + }, [completeSuggestion, completeIgnoringSuggestion]); const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => { appCommandParser.current.setChannelContext(channelID, teamID, rootID); @@ -199,7 +195,7 @@ const AppSlashSuggestion = ({ ({ - currentTeamId: observeCurrentChannelId(database), + currentTeamId: observeCurrentTeamId(database), isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), })); diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts index bad9e4ee7c..2a65f39795 100644 --- a/app/components/autocomplete/slash_suggestion/index.ts +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -4,14 +4,14 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system'; +import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system'; import SlashSuggestion from './slash_suggestion'; import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - currentTeamId: observeCurrentChannelId(database), + currentTeamId: observeCurrentTeamId(database), isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), })); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index 5dd4e0ee8f..5b96de6a28 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -9,7 +9,8 @@ import { Platform, } from 'react-native'; -import {fetchCommands, fetchSuggestions} from '@actions/remote/command'; +import {fetchSuggestions} from '@actions/remote/command'; +import IntegrationsManager from '@app/init/integrations_manager'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import analytics from '@init/analytics'; @@ -99,6 +100,8 @@ const SlashSuggestion = ({ const active = Boolean(dataSource.length); + const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]); + const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => { setDataSource(matches); onShowingChange(Boolean(matches.length)); @@ -190,11 +193,11 @@ const SlashSuggestion = ({ } if (!commands) { - fetchCommands(serverUrl, currentTeamId).then((res) => { - if (res.error) { - setCommands(emptyCommandList); + IntegrationsManager.getManager(serverUrl).fetchCommands(currentTeamId).then((res) => { + if (res.length) { + setCommands(res.filter(commandFilter)); } else { - setCommands(res.commands.filter(commandFilter)); + setCommands(emptyCommandList); } }); return; @@ -231,7 +234,7 @@ const SlashSuggestion = ({ { + const iconAsSource = useMemo(() => { + return {uri: icon}; + }, [icon]); + + const touchableStyle = useMemo(() => { + return {marginLeft: insets.left, marginRight: insets.right}; + }, [insets]); + + const completeSuggestion = useCallback(() => { onPress(complete); - }; + }, [onPress, complete]); let suggestionText = suggestion; if (suggestionText?.[0] === '/' && complete.split(' ').length === 1) { @@ -113,7 +122,7 @@ const SlashSuggestionItem = ({ } else if (icon.startsWith('http')) { image = ( ); @@ -121,21 +130,21 @@ const SlashSuggestionItem = ({ if (icon.startsWith('data:image/svg+xml')) { let xml = ''; try { - xml = Buffer.from(icon.substring('data:image/svg+xml;base64,'.length), 'base64').toString(); - } catch { + xml = base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); + image = ( + + ); + } catch (error) { // Do nothing } - image = ( - - ); } else { image = ( ); @@ -145,7 +154,7 @@ const SlashSuggestionItem = ({ return ( diff --git a/app/init/integrations_manager.ts b/app/init/integrations_manager.ts new file mode 100644 index 0000000000..86859775d9 --- /dev/null +++ b/app/init/integrations_manager.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchCommands} from '@actions/remote/command'; + +const TIME_TO_REFETCH_COMMANDS = 60000; // 1 minute +class ServerIntegrationsManager { + private serverUrl: string; + private commandsLastFetched: {[teamId: string]: number | undefined} = {}; + private commands: {[teamId: string]: Command[] | undefined} = {}; + + private triggerId = ''; + + private bindings: AppBinding[] = []; + private rhsBindings: AppBinding[] = []; + + private commandForms: {[key: string]: AppForm | undefined} = {}; + private rhsCommandForms: {[key: string]: AppForm | undefined} = {}; + + constructor(serverUrl: string) { + this.serverUrl = serverUrl; + } + + public async fetchCommands(teamId: string) { + const lastFetched = this.commandsLastFetched[teamId] || 0; + const lastCommands = this.commands[teamId]; + if (lastCommands && lastFetched + TIME_TO_REFETCH_COMMANDS > Date.now()) { + return lastCommands; + } + + try { + const res = await fetchCommands(this.serverUrl, teamId); + if (res.error) { + return []; + } + this.commands[teamId] = res.commands; + this.commandsLastFetched[teamId] = Date.now(); + return res.commands; + } catch { + return []; + } + } + + public getCommandBindings() { + // TODO filter bindings + return this.bindings; + } + + public getRHSCommandBindings() { + // TODO filter bindings + return this.rhsBindings; + } + + public getAppRHSCommandForm(key: string) { + return this.rhsCommandForms[key]; + } + public getAppCommandForm(key: string) { + return this.commandForms[key]; + } + public setAppRHSCommandForm(key: string, form: AppForm) { + this.rhsCommandForms[key] = form; + } + public setAppCommandForm(key: string, form: AppForm) { + this.commandForms[key] = form; + } + + public getTriggerId() { + return this.triggerId; + } + public setTriggerId(id: string) { + this.triggerId = id; + } +} + +class IntegrationsManager { + private serverManagers: {[serverUrl: string]: ServerIntegrationsManager | undefined} = {}; + public getManager(serverUrl: string): ServerIntegrationsManager { + if (!this.serverManagers[serverUrl]) { + this.serverManagers[serverUrl] = new ServerIntegrationsManager(serverUrl); + } + + return this.serverManagers[serverUrl]!; + } +} + +export default new IntegrationsManager(); diff --git a/package-lock.json b/package-lock.json index d6f4a29f4e..6a0df0afc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@rudderstack/rudder-sdk-react-native": "1.2.1", "@sentry/react-native": "3.2.13", "@stream-io/flat-list-mvcp": "0.10.1", + "base-64": "1.0.0", "commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d", "deep-equal": "2.0.5", @@ -109,6 +110,7 @@ "@babel/runtime": "7.17.2", "@react-native-community/eslint-config": "3.0.1", "@testing-library/react-native": "9.0.0", + "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", "@types/deep-equal": "1.0.1", @@ -5708,6 +5710,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==", + "dev": true + }, "node_modules/@types/commonmark": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz", @@ -7609,6 +7617,11 @@ "node": ">=0.10.0" } }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", @@ -28525,6 +28538,12 @@ "@babel/types": "^7.3.0" } }, + "@types/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==", + "dev": true + }, "@types/commonmark": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz", @@ -30051,6 +30070,11 @@ } } }, + "base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/package.json b/package.json index d9e1e28517..bf630838a1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@rudderstack/rudder-sdk-react-native": "1.2.1", "@sentry/react-native": "3.2.13", "@stream-io/flat-list-mvcp": "0.10.1", + "base-64": "1.0.0", "commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d", "deep-equal": "2.0.5", @@ -106,6 +107,7 @@ "@babel/runtime": "7.17.2", "@react-native-community/eslint-config": "3.0.1", "@testing-library/react-native": "9.0.0", + "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", "@types/deep-equal": "1.0.1",