diff --git a/src/atoms.ts b/src/atoms.ts index 2d87f295a2d..91bd1c7b409 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -20,6 +20,7 @@ type BotAccounts = { export const verifiedAccountsAtom = atomWithStorage>("VERIFIED_ACCOUNTS", {}); export const botAccountsAtom = atomWithStorage("BOT_ACCOUNTS", null); export const minimumTokenThresholdAtom = atomWithStorage>("TOKEN_THRESHOLD", {}); +export const botCommandsAtom = atomWithStorage>("BOT_COMMANDS", {}); export function getBotAccountData(): BotAccounts | null { const defaultStore = getDefaultStore(); diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index de058fe38e7..e2aba5b2a0b 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -27,6 +27,8 @@ import { filterBoolean } from "matrix-react-sdk/src/utils/arrays"; import { timeout } from "matrix-react-sdk/src/utils/promise"; import { ReactElement } from "react"; +import CommandProvider from "./CommandProvider"; + export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not start: number; // byte offset relative to the start anchor of the current editor selection. @@ -46,7 +48,7 @@ export interface ICompletion { href?: string; } -const PROVIDERS = [UserProvider, RoomProvider, EmojiProvider, NotifProvider, SpaceProvider]; +const PROVIDERS = [UserProvider, RoomProvider, EmojiProvider, NotifProvider, CommandProvider, SpaceProvider]; // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx new file mode 100644 index 00000000000..a3b0a4724d6 --- /dev/null +++ b/src/autocomplete/CommandProvider.tsx @@ -0,0 +1,154 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/matrix"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import { Command, CommandCategories, CommandMap } from "matrix-react-sdk/src/SlashCommands"; +import AutocompleteProvider from "matrix-react-sdk/src/autocomplete/AutocompleteProvider"; +import { TextualCompletion } from "matrix-react-sdk/src/autocomplete/Components"; +import QueryMatcher from "matrix-react-sdk/src/autocomplete/QueryMatcher"; +import { TimelineRenderingType } from "matrix-react-sdk/src/contexts/RoomContext"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import React from "react"; + +import { ICompletion, ISelectionRange } from "./Autocompleter"; + +const COMMAND_RE = /(^\/\w*)(?: .*)?/g; + +export type BotCommand = { + name: string; + arguments: { + name: string; + description: string; + }[]; + description: string; +}; + +export default class CommandProvider extends AutocompleteProvider { + public matcher: QueryMatcher; + public room: Room; + + public constructor(room: Room, renderingType?: TimelineRenderingType) { + super({ commandRegex: COMMAND_RE, renderingType }); + this.room = room; + this.matcher = new QueryMatcher(this.getRoomCommands(), { + keys: ["command", "args", "description"], + funcs: [({ aliases }): string => aliases.join(" ")], // aliases + context: renderingType, + }); + } + + public getRoomCommands(): Command[] { + const commandStorage = JSON.parse(localStorage.getItem("BOT_COMMANDS") || "{}"); + + if ( + commandStorage[this.room.name] && + this.room.getMembers().some((member) => member.userId.includes(this.room.name)) + ) { + return commandStorage[this.room.name].map( + (cmd: BotCommand) => + new Command({ + command: cmd.name, + args: cmd.arguments.map((arg) => `<${arg.name}>`).join(" "), + description: cmd.description as any, + category: CommandCategories.messages, + }), + ); + } + + return []; + } + + public async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { + const { command, range } = this.getCurrentCommand(query, selection); + if (!command) return []; + + const cli = MatrixClientPeg.get(); + + let matches: Command[] = []; + // check if the full match differs from the first word (i.e. returns false if the command has args) + if (command[0] !== command[1]) { + // The input looks like a command with arguments, perform exact match + const name = command[1].slice(1); // strip leading `/` + if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled(cli)) { + // some commands, namely `me` don't suit having the usage shown whilst typing their arguments + if (CommandMap.get(name)!.hideCompletionAfterSpace) return []; + matches = [CommandMap.get(name)!]; + } + } else { + if (query === "/") { + // If they have just entered `/` show everything + // We exclude the limit on purpose to have a comprehensive list + matches = this.getRoomCommands(); + } else { + // otherwise fuzzy match against all of the fields + matches = this.matcher.match(command[1], limit); + } + } + + return matches + .filter((cmd) => { + const display = !cmd.renderingTypes || cmd.renderingTypes.includes(this.renderingType); + return cmd.isEnabled(cli) && display; + }) + .map((result) => { + let completion = result.getCommand() + " "; + const usedAlias = result.aliases.find((alias) => `/${alias}` === command[1]); + // If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments + if (usedAlias || result.getCommand() === command[1]) { + completion = command[0]; + } + + return { + completion, + type: "command", + component: ( + + ), + range: range!, + }; + }); + } + + public getName(): string { + return "*️⃣ " + _t("composer|autocomplete|command_description"); + } + + public renderCompletions(completions: React.ReactNode[]): React.ReactNode { + return ( +
+ {completions} +
+ ); + } +} diff --git a/src/context/SuperheroProvider.tsx b/src/context/SuperheroProvider.tsx index 62bc43c700c..9494a63d74c 100644 --- a/src/context/SuperheroProvider.tsx +++ b/src/context/SuperheroProvider.tsx @@ -1,18 +1,21 @@ import { useAtom } from "jotai"; import React, { useCallback, useEffect } from "react"; -import { minimumTokenThresholdAtom, verifiedAccountsAtom, botAccountsAtom } from "../atoms"; +import { minimumTokenThresholdAtom, verifiedAccountsAtom, botAccountsAtom, botCommandsAtom } from "../atoms"; type BotAccounts = { domain: string; communityBot: { userId: string; + apiPrefix: string; }; superheroBot: { userId: string; + apiPrefix: string; }; blockchainBot: { userId: string; + apiPrefix: string; }; }; @@ -52,6 +55,7 @@ const useMinimumTokenThreshold = (config: any): void => { export const SuperheroProvider = ({ children, config }: any): any => { const [verifiedAccounts, setVerifiedAccounts] = useAtom(verifiedAccountsAtom); const [, setBotAccounts] = useAtom(botAccountsAtom); + const [, setBotCommands] = useAtom(botCommandsAtom); function loadVerifiedAccounts(): void { if (config.bots_backend_url) { @@ -78,11 +82,14 @@ export const SuperheroProvider = ({ children, config }: any): any => { superheroBot: "@" + data.superheroBot.userId + ":" + data.domain, blockchainBot: "@" + data.blockchainBot.userId + ":" + data.domain, }); + fetchBotCommands(data.communityBot); + fetchBotCommands(data.superheroBot); }) .catch(() => { // }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.bots_backend_url, setBotAccounts]); useEffect(() => { @@ -98,6 +105,22 @@ export const SuperheroProvider = ({ children, config }: any): any => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + function fetchBotCommands(bot: { userId: string; apiPrefix: string }): void { + fetch(`${config.bots_backend_url}${bot.apiPrefix}/commands`, { + method: "GET", + }) + .then((res) => res.json()) + .then((data: any) => { + setBotCommands((prev) => ({ + ...prev, + [bot.userId]: data.commands, + })); + }) + .catch(() => { + // + }); + } + /** * Handles the click event on an element. * If the target element's host is 'wallet.superhero.com', it prevents the default behavior and opens the target URL in a new window with specific dimensions.