Add integrations manager, use base-64 to handle svgs and minor improvement and fixes in the components

This commit is contained in:
Daniel Espino García
2022-03-24 18:40:38 +01:00
parent 59959d041a
commit 2393151ff0
9 changed files with 169 additions and 66 deletions

View File

@@ -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;

View File

@@ -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 (
<AtMentionItem
user={item.item as UserProfile | UserModel}
onPress={completeUserSuggestion(item.Complete)}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.at_mention.item.${item.item}`}
/>
);
@@ -137,7 +133,7 @@ const AppSlashSuggestion = ({
return (
<ChannelMentionItem
channel={item.item as Channel | ChannelModel}
onPress={completeChannelMention(item.Complete)}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.channel_mention.item.${item.item}`}
/>
);
@@ -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 = ({
<FlatList
testID='app_slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={[style.listView, {maxHeight: maxListHeight}]}
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}

View File

@@ -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 AppSlashSuggestion from './app_slash_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentChannelId(database),
currentTeamId: observeCurrentTeamId(database),
isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
}));

View File

@@ -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'),
}));

View File

@@ -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 = ({
<FlatList
testID='slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={[style.listView, {maxHeight: maxListHeight}]}
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}

View File

@@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import base64 from 'base-64';
import React, {useCallback, useMemo} from 'react';
import {Image, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -78,9 +79,17 @@ const SlashSuggestionItem = ({
const theme = useTheme();
const style = getStyleFromTheme(theme);
const completeSuggestion = () => {
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 = (
<FastImage
source={{uri: icon}}
source={iconAsSource}
style={style.uriIcon}
/>
);
@@ -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 = (
<SvgXml
xml={xml}
width={32}
height={32}
/>
);
} catch (error) {
// Do nothing
}
image = (
<SvgXml
xml={xml}
width={32}
height={32}
/>
);
} else {
image = (
<Image
source={{uri: icon}}
source={iconAsSource}
style={style.uriIcon}
/>
);
@@ -145,7 +154,7 @@ const SlashSuggestionItem = ({
return (
<TouchableWithFeedback
onPress={completeSuggestion}
style={{marginLeft: insets.left, marginRight: insets.right}}
style={touchableStyle}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type={'native'}
>

View File

@@ -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();

24
package-lock.json generated
View File

@@ -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",

View File

@@ -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",