forked from Ivasoft/mattermost-mobile
Add integrations manager, use base-64 to handle svgs and minor improvement and fixes in the components
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
86
app/init/integrations_manager.ts
Normal file
86
app/init/integrations_manager.ts
Normal 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
24
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user