From dbc42b0ec2919f3300fcac054f2ff9d3b7390118 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 10:41:19 -0500 Subject: [PATCH] better UX for gomuks built-in commands Fixes #157 --- lib/controllers/composer_draft.dart | 24 ++- lib/models/composer_draft.dart | 2 + lib/models/text_command.dart | 48 +++++ lib/widgets/composer/bot_command_panel.dart | 25 ++- lib/widgets/composer/composer.dart | 226 ++++++++++++++------ lib/widgets/html/html.dart | 15 ++ 6 files changed, 266 insertions(+), 74 deletions(-) create mode 100644 lib/models/text_command.dart diff --git a/lib/controllers/composer_draft.dart b/lib/controllers/composer_draft.dart index dc99c8e..25ed273 100644 --- a/lib/controllers/composer_draft.dart +++ b/lib/controllers/composer_draft.dart @@ -9,6 +9,7 @@ import "package:nexus/models/composer_draft.dart"; import "package:nexus/models/composer_params.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/text_command.dart"; class ComposerDraftController(final ComposerParams composer) extends Notifier { @@ -35,19 +36,22 @@ class ComposerDraftController(final ComposerParams composer) .read(RoomsController.provider)[composer.roomId] ?.withLatestEdit(rowId); final content = event?.content; - final text = - event?.localContent?.editSource ?? - switch (content) { - TextMessageContent(:final body) || - ImageMessageContent(:final body) || - VideoMessageContent(:final body) || - AudioMessageContent(:final body) || - FileMessageContent(:final body) => body, - _ => "", - }; + // Edit sources start with text commands, like "/me " + final (:commands, :text) = TextCommand.parse( + event?.localContent?.editSource ?? + switch (content) { + TextMessageContent(:final body) || + ImageMessageContent(:final body) || + VideoMessageContent(:final body) || + AudioMessageContent(:final body) || + FileMessageContent(:final body) => body, + _ => "", + }, + ); state = ComposerDraft( text: text, + textCommands: commands, relatedEvent: rowId, relationType: .edit, beforeEdit: diff --git a/lib/models/composer_draft.dart b/lib/models/composer_draft.dart index 9f8748f..ea72f30 100644 --- a/lib/models/composer_draft.dart +++ b/lib/models/composer_draft.dart @@ -6,6 +6,7 @@ import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/text_command.dart"; part "composer_draft.freezed.dart"; @@ -14,6 +15,7 @@ class const ComposerDraft({ // Formatted like ":id#name#", see the composer's tagTextFormatter final String text = "", final IList tags = const .empty(), + final IList textCommands = const .empty(), final int? relatedEvent, final RelationType relationType = .reply, diff --git a/lib/models/text_command.dart b/lib/models/text_command.dart new file mode 100644 index 0000000..7ee2f77 --- /dev/null +++ b/lib/models/text_command.dart @@ -0,0 +1,48 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +enum TextCommandGroup { messageType, format } + +// Prefixes gomuks parses out of sent text, in the order it expects them +enum TextCommand(final String description, {final TextCommandGroup? group}) { + me("Send as an emote", group: .messageType), + notice("Send as a notice", group: .messageType), + unencrypted("Send unencrypted, even if the room is encrypted"), + rawinputbody("Use the text as the body as-is, rather than from the HTML"), + plain("Send without any formatting", group: .format), + html("Send with only HTML, without markdown", group: .format), + htmlmd("Send with both HTML and markdown", group: .format), + rainbow("Send in rainbow colors, with markdown", group: .format); + + String get prefix => "/$name"; + + bool completes(String text) => + text.startsWith("/") && prefix.startsWith(text.toLowerCase()); + + // Replaces any command from the same group, as gomuks only uses one + IList addTo(IList commands) => .new( + commands + .where( + (other) => other != this && (group == null || other.group != group), + ) + .toIList() + .add(this) + .sortedBy((command) => command.index), + ); + + static String prefixOf(IList commands) => + commands.map((command) => "${command.prefix} ").join(); + + static ({IList commands, String text}) parse( + String text, [ + IList commands = const .empty(), + ]) => switch (values.firstWhereOrNull( + (command) => text.toLowerCase().startsWith("${command.prefix} "), + )) { + final command? => parse( + text.substring(command.prefix.length + 1), + command.addTo(commands), + ), + null => (commands: commands, text: text), + }; +} diff --git a/lib/widgets/composer/bot_command_panel.dart b/lib/widgets/composer/bot_command_panel.dart index 57783ac..8a06754 100644 --- a/lib/widgets/composer/bot_command_panel.dart +++ b/lib/widgets/composer/bot_command_panel.dart @@ -8,13 +8,16 @@ import "package:nexus/controllers/bot_argument_suggestions.dart"; import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/text_command.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class const BotCommandPanel( final ComposerParams composer, { required final IList commands, + required final IList textCommands, required final TextEditingController controller, required final void Function(BotCommand command) onPick, + required final void Function(TextCommand command) onPickText, super.key, }) extends HookConsumerWidget { @override @@ -49,13 +52,27 @@ class const BotCommandPanel( ); final children = switch (ref.watch(draftProvider)) { - null => - commands + null => [ + ...textCommands + .where((command) => command.completes(text.value)) + .map( + (command) => ListTile( + leading: AvatarOrHash(null, "gomuks", dimension: 32), + title: Text(command.prefix), + subtitle: Text( + "gomuks ยท ${command.description}", + maxLines: 1, + overflow: .ellipsis, + ), + onTap: () => onPickText(command), + ), + ), + ...commands .where((command) => command.completes(text.value)) .map( (command) => commandTile(command, onTap: () => onPick(command)), - ) - .toList(), + ), + ], final draft => [ commandTile(draft.command), if (draft.focusedParameter case final parameter?) diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 9376318..56f02b8 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -14,6 +14,7 @@ import "package:nexus/helpers/extensions/with_latest_edit.dart"; import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command_draft.dart"; import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/text_command.dart"; import "package:nexus/widgets/composer/bot_command_form.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart"; import "package:nexus/widgets/emoji_picker.dart"; @@ -90,6 +91,7 @@ class const Composer( :sticker, :poll, :location, + :textCommands, ) = ref.watch( draftProvider.select( (draft) => ( @@ -99,6 +101,7 @@ class const Composer( sticker: draft.sticker, poll: draft.poll, location: draft.location, + textCommands: draft.textCommands, ), ), ); @@ -269,11 +272,57 @@ class const Composer( return false; } - final botCommands = isEditing || hasReplacement || attachment != null + // Bot commands can't follow text commands, which gomuks sends as text + final botCommands = + isEditing || + hasReplacement || + attachment != null || + textCommands.isNotEmpty ? const IList.empty() : ref.watch(BotCommandsController.provider(roomId)).value ?? const .empty(); + final availableTextCommands = hasReplacement || attachment != null + ? const IList.empty() + : TextCommand.values + .where((command) => !textCommands.contains(command)) + .toIList(); + + void setTextCommands(IList commands) => + draftNotifier.update((draft) => draft.copyWith(textCommands: commands)); + + void pickTextCommand(TextCommand command) { + setTextCommands(command.addTo(textCommands)); + controller.text = ""; + node?.requestFocus(); + } + + void removeTextCommand(TextCommand command) { + setTextCommands(textCommands.remove(command)); + node?.requestFocus(); + } + + /// Turns text commands typed at the start, like "/me ", into chips. + /// Returns whether there were any. + bool pickTypedTextCommands(String text) { + if (availableTextCommands.isEmpty) return false; + final parsed = TextCommand.parse(text, textCommands); + if (parsed.text == text) return false; + + setTextCommands(parsed.commands); + final removed = text.length - parsed.text.length; + controller.value = .new( + text: parsed.text, + selection: .collapsed( + offset: (controller.selection.baseOffset - removed).clamp( + 0, + parsed.text.length, + ), + ), + ); + return true; + } + void pickBotCommand(BotCommand command) { ref .read(botCommandProvider.notifier) @@ -290,7 +339,8 @@ class const Composer( [] => null, }; - void pickTypedBotCommand(String text) { + void pickTypedCommand(String text) { + if (pickTypedTextCommands(text)) return; if (botCommands.any((command) => command.prefix.startsWith(text))) return; if (typedBotCommand(text) case final command?) pickBotCommand(command); } @@ -352,10 +402,19 @@ class const Composer( return; } - if (typedBotCommand(controller.text) ?? - botCommands.firstWhereOrNull( - (command) => command.completes(controller.text), - ) + // In the order the command panel shows them + if (typedBotCommand(controller.text) case final command?) { + return pickBotCommand(command); + } + if (availableTextCommands.firstWhereOrNull( + (command) => command.completes(controller.text), + ) + case final command?) { + return pickTextCommand(command); + } + if (botCommands.firstWhereOrNull( + (command) => command.completes(controller.text), + ) case final command?) { return pickBotCommand(command); } @@ -365,13 +424,14 @@ class const Composer( return; } onSend( - controller.formattedText, + "${TextCommand.prefixOf(textCommands)}${controller.formattedText}", shouldMention: shouldMention.value, tags: .new(controller.tags), ); // Before dismissing, which restores the draft from before an edit controller.text = ""; + setTextCommands(const .empty()); dismiss(); } @@ -386,6 +446,25 @@ class const Composer( return .ignored; } + KeyEventResult onTextFieldKeyEvent(FocusNode node, KeyEvent event) { + if (textCommands.lastOrNull case final command? + when event is KeyDownEvent && + event.logicalKey == .backspace && + controller.selection.isCollapsed && + controller.selection.baseOffset == 0) { + setTextCommands(textCommands.removeLast()); + if (controller.text.isEmpty) { + controller.value = .new( + text: command.prefix, + selection: .collapsed(offset: command.prefix.length), + ); + } + return .handled; + } + + return sendOnEnter(node, event); + } + final style = TextStyle( color: theme.colorScheme.primary, fontWeight: .bold, @@ -419,8 +498,10 @@ class const Composer( child: BotCommandPanel( composer, commands: botCommands, + textCommands: availableTextCommands, controller: controller, onPick: pickBotCommand, + onPickText: pickTextCommand, ), ), ClipRRect( @@ -731,7 +812,7 @@ class const Composer( ":": style, }, builder: (context, key) => Focus( - onKeyEvent: sendOnEnter, + onKeyEvent: onTextFieldKeyEvent, // Overrides the keyboard shortcut for pasting child: Actions( actions: { @@ -739,60 +820,85 @@ class const Composer( pasteFiles, ), }, - child: TextField( - maxLines: 12, - minLines: 1, - textCapitalization: .sentences, - onChanged: pickTypedBotCommand, - autofocus: - (Platform.isLinux || - Platform.isMacOS || - Platform.isWindows), - decoration: .new( - hintText: "Your message here...", - border: .none, - ), - controller: controller, - key: key, - focusNode: node, - // The toolbar's paste button doesn't use the shortcut's action, so replace it too - contextMenuBuilder: (_, editableText) => - AdaptiveTextSelectionToolbar.buttonItems( - anchors: editableText - .contextMenuAnchors, - buttonItems: editableText - .contextMenuButtonItems - .map( - (item) => - item.type == .paste - ? item.copyWith( - onPressed: () async { - editableText - .hideToolbar(); - if (!await pasteFiles()) { - item.onPressed - ?.call(); - } - }, - ) - : item, - ) - .toList(), + child: Row( + spacing: 4, + children: [ + ...textCommands.map( + (command) => InputChip( + label: Text(command.prefix), + tooltip: command.description, + visualDensity: .compact, + materialTapTargetSize: + .shrinkWrap, + onDeleted: () => + removeTextCommand(command), ), - // Images pasted from Android keyboards' clipboards are inserted as content - contentInsertionConfiguration: .new( - onContentInserted: (content) { - if (attachment != null) return; + ), + Expanded( + child: TextField( + maxLines: 12, + minLines: 1, + textCapitalization: .sentences, + onChanged: pickTypedCommand, + autofocus: + (Platform.isLinux || + Platform.isMacOS || + Platform.isWindows), + decoration: .new( + hintText: + "Your message here...", + border: .none, + ), + controller: controller, + key: key, + focusNode: node, + // The toolbar's paste button doesn't use the shortcut's action, so replace it too + contextMenuBuilder: + ( + _, + editableText, + ) => AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableText + .contextMenuAnchors, + buttonItems: editableText + .contextMenuButtonItems + .map( + (item) => + item.type == + .paste + ? item.copyWith( + onPressed: () async { + editableText + .hideToolbar(); + if (!await pasteFiles()) { + item.onPressed + ?.call(); + } + }, + ) + : item, + ) + .toList(), + ), + // Images pasted from Android keyboards' clipboards are inserted as content + contentInsertionConfiguration: .new( + onContentInserted: (content) { + if (attachment != null) { + return; + } - if (content.data - case final data?) { - attachBytes( - data, - "image.${content.mimeType.split("/").last}", - ).onError(showError); - } - }, - ), + if (content.data + case final data?) { + attachBytes( + data, + "image.${content.mimeType.split("/").last}", + ).onError(showError); + } + }, + ), + ), + ), + ], ), ), ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index d356f6e..d717a8a 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; +import "package:html/dom.dart" as dom; import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/mxc_image.dart"; @@ -65,6 +66,20 @@ class const Html( Html(element.innerHtml, textStyle: textStyle, roomId: roomId), ), + "font" => InlineCustomWidget( + child: Html( + (dom.Element.tag("span") + ..attributes.addAll({ + "data-mx-color": ?element.attributes["color"], + ...element.attributes, + }) + ..innerHtml = element.innerHtml) + .outerHtml, + textStyle: textStyle, + roomId: roomId, + ), + ), + "a" => element.attributes["href"]?.linkInfo == null ? null