diff --git a/src/SlashCommands.js b/src/SlashCommands.tsx similarity index 91% rename from src/SlashCommands.js rename to src/SlashCommands.tsx index 72ca4b1566d..c62aa9c3e56 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.tsx @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +18,8 @@ limitations under the License. */ -import React from 'react'; +import * as React from 'react'; + import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher'; import * as sdk from './index'; @@ -34,11 +36,16 @@ import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/I import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; import {inviteUsersToRoom} from "./RoomInvite"; -const singleMxcUpload = async () => { +// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 +interface HTMLInputEvent extends Event { + target: HTMLInputElement & EventTarget; +} + +const singleMxcUpload = async (): Promise => { return new Promise((resolve) => { const fileSelector = document.createElement('input'); fileSelector.setAttribute('type', 'file'); - fileSelector.onchange = (ev) => { + fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); @@ -62,28 +69,49 @@ export const CommandCategories = { "other": _td("Other"), }; +type RunFn = ((roomId: string, args: string, cmd: string) => {error: any} | {promise: Promise}); + +interface ICommandOpts { + command: string; + aliases?: string[]; + args?: string; + description: string; + runFn?: RunFn; + category: string; + hideCompletionAfterSpace?: boolean; +} + class Command { - constructor({name, args='', description, runFn, category=CommandCategories.other, hideCompletionAfterSpace=false}) { - this.command = '/' + name; - this.args = args; - this.description = description; - this.runFn = runFn; - this.category = category; - this.hideCompletionAfterSpace = hideCompletionAfterSpace; + command: string; + aliases: string[]; + args: undefined | string; + description: string; + runFn: undefined | RunFn; + category: string; + hideCompletionAfterSpace: boolean; + + constructor(opts: ICommandOpts) { + this.command = opts.command; + this.aliases = opts.aliases || []; + this.args = opts.args || ""; + this.description = opts.description; + this.runFn = opts.runFn; + this.category = opts.category || CommandCategories.other; + this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; } getCommand() { - return this.command; + return `/${this.command}`; } getCommandWithArgs() { return this.getCommand() + " " + this.args; } - run(roomId, args) { + run(roomId: string, args: string, cmd: string) { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) return; - return this.runFn.bind(this)(roomId, args); + return this.runFn.bind(this)(roomId, args, cmd); } getUsage() { @@ -95,7 +123,7 @@ function reject(error) { return {error}; } -function success(promise) { +function success(promise?: Promise) { return {promise}; } @@ -103,11 +131,9 @@ function success(promise) { * functions are called with `this` bound to the Command instance. */ -/* eslint-disable babel/no-invalid-this */ - -export const CommandMap = { - shrug: new Command({ - name: 'shrug', +export const Commands = [ + new Command({ + command: 'shrug', args: '', description: _td('Prepends ¯\\_(ツ)_/¯ to a plain-text message'), runFn: function(roomId, args) { @@ -119,8 +145,8 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - plain: new Command({ - name: 'plain', + new Command({ + command: 'plain', args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { @@ -128,8 +154,8 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - html: new Command({ - name: 'html', + new Command({ + command: 'html', args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { @@ -137,11 +163,11 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - ddg: new Command({ - name: 'ddg', + new Command({ + command: 'ddg', args: '', description: _td('Searches DuckDuckGo for results'), - runFn: function(roomId, args) { + runFn: function() { const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { @@ -153,9 +179,8 @@ export const CommandMap = { category: CommandCategories.actions, hideCompletionAfterSpace: true, }), - - upgraderoom: new Command({ - name: 'upgraderoom', + new Command({ + command: 'upgraderoom', args: '', description: _td('Upgrades a room to a new version'), runFn: function(roomId, args) { @@ -224,9 +249,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - nick: new Command({ - name: 'nick', + new Command({ + command: 'nick', args: '', description: _td('Changes your display nickname'), runFn: function(roomId, args) { @@ -237,9 +261,9 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - myroomnick: new Command({ - name: 'myroomnick', + new Command({ + command: 'myroomnick', + aliases: ['roomnick'], args: '', description: _td('Changes your display nickname in the current room only'), runFn: function(roomId, args) { @@ -256,9 +280,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - roomavatar: new Command({ - name: 'roomavatar', + new Command({ + command: 'roomavatar', args: '[]', description: _td('Changes the avatar of the current room'), runFn: function(roomId, args) { @@ -274,9 +297,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - myroomavatar: new Command({ - name: 'myroomavatar', + new Command({ + command: 'myroomavatar', args: '[]', description: _td('Changes your avatar in this current room only'), runFn: function(roomId, args) { @@ -301,9 +323,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - myavatar: new Command({ - name: 'myavatar', + new Command({ + command: 'myavatar', args: '[]', description: _td('Changes your avatar in all rooms'), runFn: function(roomId, args) { @@ -319,9 +340,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - topic: new Command({ - name: 'topic', + new Command({ + command: 'topic', args: '[]', description: _td('Gets or sets the room topic'), runFn: function(roomId, args) { @@ -345,9 +365,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - roomname: new Command({ - name: 'roomname', + new Command({ + command: 'roomname', args: '', description: _td('Sets the room name'), runFn: function(roomId, args) { @@ -358,9 +377,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - invite: new Command({ - name: 'invite', + new Command({ + command: 'invite', args: '', description: _td('Invites user with given id to current room'), runFn: function(roomId, args) { @@ -399,7 +417,7 @@ export const CommandMap = { } } const inviter = new MultiInviter(roomId); - return success(finished.then(([useDefault] = []) => { + return success(finished.then(([useDefault]: any) => { if (useDefault) { useDefaultIdentityServer(); } else if (useDefault === false) { @@ -417,12 +435,12 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - join: new Command({ - name: 'join', + new Command({ + command: 'join', + aliases: ['j', 'goto'], args: '', description: _td('Joins room with given alias'), - runFn: function(roomId, args) { + runFn: function(_, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -530,9 +548,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - part: new Command({ - name: 'part', + new Command({ + command: 'part', args: '[]', description: _td('Leave room'), runFn: function(roomId, args) { @@ -578,9 +595,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - kick: new Command({ - name: 'kick', + new Command({ + command: 'kick', args: ' [reason]', description: _td('Kicks user with given id'), runFn: function(roomId, args) { @@ -594,10 +610,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - // Ban a user from the room with an optional reason - ban: new Command({ - name: 'ban', + new Command({ + command: 'ban', args: ' [reason]', description: _td('Bans user with given id'), runFn: function(roomId, args) { @@ -611,10 +625,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - // Unban a user from ythe room - unban: new Command({ - name: 'unban', + new Command({ + command: 'unban', args: '', description: _td('Unbans user with given ID'), runFn: function(roomId, args) { @@ -629,9 +641,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - ignore: new Command({ - name: 'ignore', + new Command({ + command: 'ignore', args: '', description: _td('Ignores a user, hiding their messages from you'), runFn: function(roomId, args) { @@ -660,9 +671,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - unignore: new Command({ - name: 'unignore', + new Command({ + command: 'unignore', args: '', description: _td('Stops ignoring a user, showing their messages going forward'), runFn: function(roomId, args) { @@ -692,10 +702,8 @@ export const CommandMap = { }, category: CommandCategories.actions, }), - - // Define the power level of a user - op: new Command({ - name: 'op', + new Command({ + command: 'op', args: ' []', description: _td('Define the power level of a user'), runFn: function(roomId, args) { @@ -705,7 +713,7 @@ export const CommandMap = { if (matches) { const userId = matches[1]; if (matches.length === 4 && undefined !== matches[3]) { - powerLevel = parseInt(matches[3]); + powerLevel = parseInt(matches[3], 10); } if (!isNaN(powerLevel)) { const cli = MatrixClientPeg.get(); @@ -721,10 +729,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - // Reset the power level of a user - deop: new Command({ - name: 'deop', + new Command({ + command: 'deop', args: '', description: _td('Deops user with given id'), runFn: function(roomId, args) { @@ -743,9 +749,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - devtools: new Command({ - name: 'devtools', + new Command({ + command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); @@ -754,9 +759,8 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), - - addwidget: new Command({ - name: 'addwidget', + new Command({ + command: 'addwidget', args: '', description: _td('Adds a custom widget by URL to the room'), runFn: function(roomId, args) { @@ -775,10 +779,8 @@ export const CommandMap = { }, category: CommandCategories.admin, }), - - // Verify a user, device, and pubkey tuple - verify: new Command({ - name: 'verify', + new Command({ + command: 'verify', args: ' ', description: _td('Verifies a user, session, and pubkey tuple'), runFn: function(roomId, args) { @@ -843,20 +845,8 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), - - // Command definitions for autocompletion ONLY: - - // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes - me: new Command({ - name: 'me', - args: '', - description: _td('Displays action'), - category: CommandCategories.messages, - hideCompletionAfterSpace: true, - }), - - discardsession: new Command({ - name: 'discardsession', + new Command({ + command: 'discardsession', description: _td('Forces the current outbound group session in an encrypted room to be discarded'), runFn: function(roomId) { try { @@ -868,9 +858,8 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), - - rainbow: new Command({ - name: "rainbow", + new Command({ + command: "rainbow", description: _td("Sends the given message coloured as a rainbow"), args: '', runFn: function(roomId, args) { @@ -879,9 +868,8 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - - rainbowme: new Command({ - name: "rainbowme", + new Command({ + command: "rainbowme", description: _td("Sends the given emote coloured as a rainbow"), args: '', runFn: function(roomId, args) { @@ -890,9 +878,8 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - - help: new Command({ - name: "help", + new Command({ + command: "help", description: _td("Displays list of commands with usages and descriptions"), runFn: function() { const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); @@ -902,18 +889,16 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), - - whois: new Command({ - name: "whois", + new Command({ + command: "whois", description: _td("Displays information about a user"), - args: '', + args: "", runFn: function(roomId, userId) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) { return reject(this.getUsage()); } const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); - dis.dispatch({ action: 'view_user', member: member || {userId}, @@ -922,17 +907,26 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), -}; -/* eslint-enable babel/no-invalid-this */ - -// helpful aliases -const aliases = { - j: "join", - newballsplease: "discardsession", - goto: "join", // because it handles event permalinks magically - roomnick: "myroomnick", -}; + // Command definitions for autocompletion ONLY: + // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes + new Command({ + command: 'me', + args: '', + description: _td('Displays action'), + category: CommandCategories.messages, + hideCompletionAfterSpace: true, + }), +]; + +// build a map from names and aliases to the Command objects. +export const CommandMap = new Map(); +Commands.forEach(cmd => { + CommandMap.set(cmd.command, cmd); + cmd.aliases.forEach(alias => { + CommandMap.set(alias, cmd); + }); +}); /** @@ -959,10 +953,7 @@ export function getCommand(roomId, input) { cmd = input; } - if (aliases[cmd]) { - cmd = aliases[cmd]; - } - if (CommandMap[cmd]) { - return () => CommandMap[cmd].run(roomId, args); + if (CommandMap.has(cmd)) { + return () => CommandMap.get(cmd).run(roomId, args, cmd); } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index da8fa3ed3c5..0b8af4d6f9b 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -23,17 +23,16 @@ import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; import {TextualCompletion} from './Components'; import type {Completion, SelectionRange} from "./Autocompleter"; -import {CommandMap} from '../SlashCommands'; - -const COMMANDS = Object.values(CommandMap); +import {Commands, CommandMap} from '../SlashCommands'; const COMMAND_RE = /(^\/\w*)(?: .*)?/g; export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); - this.matcher = new QueryMatcher(COMMANDS, { - keys: ['command', 'args', 'description'], + this.matcher = new QueryMatcher(Commands, { + keys: ['command', 'args', 'description'], + funcs: [({aliases}) => aliases.join(" ")], // aliases }); } @@ -46,31 +45,40 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].substr(1); // strip leading `/` - if (CommandMap[name]) { + if (CommandMap.has(name)) { // some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments - if (CommandMap[name].hideCompletionAfterSpace) return []; - matches = [CommandMap[name]]; + if (CommandMap.get(name).hideCompletionAfterSpace) return []; + matches = [CommandMap.get(name)]; } } else { if (query === '/') { // If they have just entered `/` show everything - matches = COMMANDS; + matches = Commands; } else { // otherwise fuzzy match against all of the fields matches = this.matcher.match(command[1]); } } - return matches.map((result) => ({ - // If the command is the same as the one they entered, we don't want to discard their arguments - completion: result.command === command[1] ? command[0] : (result.command + ' '), - type: "command", - component: , - range, - })); + + return matches.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, + }; + }); } getName() { diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.js b/src/components/views/dialogs/SlashCommandHelpDialog.js index 9e48a92ed1e..bae5b37993c 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.js +++ b/src/components/views/dialogs/SlashCommandHelpDialog.js @@ -16,14 +16,14 @@ limitations under the License. import React from 'react'; import {_t} from "../../../languageHandler"; -import {CommandCategories, CommandMap} from "../../../SlashCommands"; +import {CommandCategories, Commands} from "../../../SlashCommands"; import * as sdk from "../../../index"; export default ({onFinished}) => { const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); const categories = {}; - Object.values(CommandMap).forEach(cmd => { + Commands.forEach(cmd => { if (!categories[cmd.category]) { categories[cmd.category] = []; } @@ -41,7 +41,7 @@ export default ({onFinished}) => { categories[category].forEach(cmd => { rows.push( - {cmd.command} + {cmd.getCommand()} {cmd.args} {cmd.description} ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 20ac0c2bb1b..0e9aa3c83f3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -198,12 +198,12 @@ "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!", "Verified key": "Verified key", "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.", - "Displays action": "Displays action", "Forces the current outbound group session in an encrypted room to be discarded": "Forces the current outbound group session in an encrypted room to be discarded", "Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow", "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow", "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", "Displays information about a user": "Displays information about a user", + "Displays action": "Displays action", "Reason": "Reason", "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.", "%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",