From 7d25bbb5392672d38302c84b4a762b0ecca23617 Mon Sep 17 00:00:00 2001 From: Steef Hegeman Date: Thu, 10 Jun 2021 20:59:24 +0200 Subject: [PATCH 1/2] InputBar: suggestions for /commands with hints --- assets/l10n/intl_en.arb | 70 ++++++++++++++++++++++++++++ lib/widgets/input_bar.dart | 93 ++++++++++++++++++++++++++++++++++---- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 094405b5..5e30ff62 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -16,6 +16,76 @@ "type": "text", "placeholders": {} }, + "commandHintSend" : "Send text", + "@commandHintSend": { + "type": "text", + "description": "Usage hint for the command /send" + }, + "commandHintMe" : "Describe yourself", + "@commandHintMe": { + "type": "text", + "description": "Usage hint for the command /me" + }, + "commandHintPlain" : "Send unformatted text", + "@commandHintPlain": { + "type": "text", + "description": "Usage hint for the command /plain" + }, + "commandHintHtml" : "Send HTML-formatted text", + "@commandHintHtml": { + "type": "text", + "description": "Usage hint for the command /html" + }, + "commandHintReact" : "Send reply as a reaction", + "@commandHintReact": { + "type": "text", + "description": "Usage hint for the command /react" + }, + "commandHintJoin" : "Join the given room", + "@commandHintJoin": { + "type": "text", + "description": "Usage hint for the command /join" + }, + "commandHintLeave" : "Leave this room", + "@commandHintLeave": { + "type": "text", + "description": "Usage hint for the command /leave" + }, + "commandHintOp" : "Set the given user's power level (default: 50)", + "@commandHintOp": { + "type": "text", + "description": "Usage hint for the command /op" + }, + "commandHintKick" : "Remove the given user from this room", + "@commandHintKick": { + "type": "text", + "description": "Usage hint for the command /kick" + }, + "commandHintBan" : "Ban the given user from this room", + "@commandHintBan": { + "type": "text", + "description": "Usage hint for the command /ban" + }, + "commandHintUnBan" : "Unban the given user from this room", + "@commandHintUnBan": { + "type": "text", + "description": "Usage hint for the command /unban" + }, + "commandHintInvite" : "Invite the given user to this room", + "@commandHintInvite": { + "type": "text", + "description": "Usage hint for the command /invite" + }, + "commandHintMyRoomNick" : "Set your display name for this room", + "@commandHintMyRoomNick": { + "type": "text", + "description": "Usage hint for the command /myroomnick" + }, + "commandHintMyRoomAvatar" : "Set your picture for this room (by mxc-uri)", + "@commandHintMyRoomAvatar": { + "type": "text", + "description": "Usage hint for the command /myroomavatar" + }, "editRoomAliases": "Edit room aliases", "@editRoomAliases": { "type": "text", diff --git a/lib/widgets/input_bar.dart b/lib/widgets/input_bar.dart index 98ed56eb..6c2f76d4 100644 --- a/lib/widgets/input_bar.dart +++ b/lib/widgets/input_bar.dart @@ -2,6 +2,7 @@ import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter/services.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -41,9 +42,24 @@ class InputBar extends StatelessWidget { final searchText = controller.text.substring(0, controller.selection.baseOffset); final ret = >[]; + const maxResults = 10; + + final commandMatch = RegExp(r'^\/([\w]*)$').firstMatch(searchText); + if (commandMatch != null) { + final commandSearch = commandMatch[1].toLowerCase(); + for (final command in room.client.commands.keys) { + if (command.contains(commandSearch)) { + ret.add({ + 'type': 'command', + 'name': command, + }); + } + + if (ret.length > maxResults) return ret; + } + } final emojiMatch = RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText); - final MAX_RESULTS = 10; if (emojiMatch != null) { final packSearch = emojiMatch[1]; final emoteSearch = emojiMatch[2].toLowerCase(); @@ -59,11 +75,11 @@ class InputBar extends StatelessWidget { 'mxc': emote.value, }); } - if (ret.length > MAX_RESULTS) { + if (ret.length > maxResults) { break; } } - if (ret.length > MAX_RESULTS) { + if (ret.length > maxResults) { break; } } @@ -77,7 +93,7 @@ class InputBar extends StatelessWidget { 'mxc': emote.value, }); } - if (ret.length > MAX_RESULTS) { + if (ret.length > maxResults) { break; } } @@ -97,7 +113,7 @@ class InputBar extends StatelessWidget { 'avatar_url': user.avatarUrl?.toString(), }); } - if (ret.length > MAX_RESULTS) { + if (ret.length > maxResults) { break; } } @@ -133,7 +149,7 @@ class InputBar extends StatelessWidget { 'avatar_url': r.avatar?.toString(), }); } - if (ret.length > MAX_RESULTS) { + if (ret.length > maxResults) { break; } } @@ -141,13 +157,64 @@ class InputBar extends StatelessWidget { return ret; } + String _commandHint(L10n l10n, String command) { + switch (command) { + case 'send': + return l10n.commandHintSend; + case 'me': + return l10n.commandHintMe; + case 'plain': + return l10n.commandHintPlain; + case 'html': + return l10n.commandHintHtml; + case 'react': + return l10n.commandHintReact; + case 'join': + return l10n.commandHintJoin; + case 'leave': + return l10n.commandHintLeave; + case 'op': + return l10n.commandHintOp; + case 'kick': + return l10n.commandHintKick; + case 'ban': + return l10n.commandHintBan; + case 'unban': + return l10n.commandHintUnBan; + case 'invite': + return l10n.commandHintInvite; + case 'myroomnick': + return l10n.commandHintMyRoomNick; + case 'myroomavatar': + return l10n.commandHintMyRoomAvatar; + default: + return ''; + } + } + Widget buildSuggestion( BuildContext context, Map suggestion, Client client, ) { + const size = 30.0; + const padding = EdgeInsets.all(4.0); + if (suggestion['type'] == 'command') { + final command = suggestion['name']; + return Container( + padding: padding, + height: size + padding.bottom + padding.top, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('/' + command, style: TextStyle(fontFamily: 'monospace')), + Text(_commandHint(L10n.of(context), command), + style: Theme.of(context).textTheme.caption), + ], + ), + ); + } if (suggestion['type'] == 'emote') { - final size = 30.0; final ratio = MediaQuery.of(context).devicePixelRatio; final url = Uri.parse(suggestion['mxc'] ?? '')?.getThumbnail( room.client, @@ -157,7 +224,7 @@ class InputBar extends StatelessWidget { animated: true, ); return Container( - padding: EdgeInsets.all(4.0), + padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -182,10 +249,9 @@ class InputBar extends StatelessWidget { ); } if (suggestion['type'] == 'user' || suggestion['type'] == 'room') { - final size = 30.0; final url = Uri.parse(suggestion['avatar_url'] ?? ''); return Container( - padding: EdgeInsets.all(4.0), + padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -212,6 +278,13 @@ class InputBar extends StatelessWidget { ? '' : controller.text.substring(controller.selection.baseOffset + 1); var insertText = ''; + if (suggestion['type'] == 'command') { + insertText = suggestion['name'] + ' '; + startText = replaceText.replaceAllMapped( + RegExp(r'^(\/[\w]*)$'), + (Match m) => '/' + insertText, + ); + } if (suggestion['type'] == 'emote') { var isUnique = true; final insertEmote = suggestion['name']; From 65b1215187ae737017bc486995aac37d0cff0651 Mon Sep 17 00:00:00 2001 From: Steef Hegeman Date: Mon, 14 Jun 2021 00:03:06 +0200 Subject: [PATCH 2/2] /commands: missing command dialog When sending a message, show an alert dialog if a command is not recognized, offering to either cancel or send as text. --- assets/l10n/intl_en.arb | 16 ++++++++++++++++ lib/pages/chat.dart | 25 +++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 5e30ff62..1495825f 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -86,6 +86,18 @@ "type": "text", "description": "Usage hint for the command /myroomavatar" }, + "commandInvalid": "Command invalid", + "@commandInvalid": { + "type": "text" + }, + "commandMissing": "{command} is not a command.", + "@commandMissing": { + "type": "text", + "placeholders": { + "command": {} + }, + "description": "State that {command} is not a valid /command." + }, "editRoomAliases": "Edit room aliases", "@editRoomAliases": { "type": "text", @@ -1849,6 +1861,10 @@ "type": "text", "placeholders": {} }, + "sendAsText": "Send as text", + "@sendAsText": { + "type": "text" + }, "sendAudio": "Send audio", "@sendAudio": { "type": "text", diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index ccc9814c..ad5dd3f6 100644 --- a/lib/pages/chat.dart +++ b/lib/pages/chat.dart @@ -215,10 +215,31 @@ class ChatController extends State { TextEditingController sendController = TextEditingController(); - void send() { + Future send() async { if (sendController.text.trim().isEmpty) return; + var parseCommands = true; + + final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text); + if (commandMatch != null && + !room.client.commands.keys.contains(commandMatch[1].toLowerCase())) { + final l10n = L10n.of(context); + final dialogResult = await showOkCancelAlertDialog( + context: context, + useRootNavigator: false, + title: l10n.commandInvalid, + message: l10n.commandMissing(commandMatch[0]), + okLabel: l10n.sendAsText, + cancelLabel: l10n.cancel, + ); + if (dialogResult == null || dialogResult == OkCancelResult.cancel) return; + parseCommands = false; + } + + // ignore: unawaited_futures room.sendTextEvent(sendController.text, - inReplyTo: replyEvent, editEventId: editEvent?.eventId); + inReplyTo: replyEvent, + editEventId: editEvent?.eventId, + parseCommands: parseCommands); sendController.value = TextEditingValue( text: pendingText, selection: TextSelection.collapsed(offset: 0),