better UX for gomuks built-in commands

Fixes #157
This commit is contained in:
Henry Hiles 2026-11-06 10:41:19 -05:00
commit dbc42b0ec2
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 267 additions and 75 deletions

View file

@ -9,6 +9,7 @@ import "package:nexus/models/composer_draft.dart";
import "package:nexus/models/composer_params.dart"; import "package:nexus/models/composer_params.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/text_command.dart";
class ComposerDraftController(final ComposerParams composer) class ComposerDraftController(final ComposerParams composer)
extends Notifier<ComposerDraft> { extends Notifier<ComposerDraft> {
@ -35,19 +36,22 @@ class ComposerDraftController(final ComposerParams composer)
.read(RoomsController.provider)[composer.roomId] .read(RoomsController.provider)[composer.roomId]
?.withLatestEdit(rowId); ?.withLatestEdit(rowId);
final content = event?.content; final content = event?.content;
final text = // Edit sources start with text commands, like "/me "
event?.localContent?.editSource ?? final (:commands, :text) = TextCommand.parse(
switch (content) { event?.localContent?.editSource ??
TextMessageContent(:final body) || switch (content) {
ImageMessageContent(:final body) || TextMessageContent(:final body) ||
VideoMessageContent(:final body) || ImageMessageContent(:final body) ||
AudioMessageContent(:final body) || VideoMessageContent(:final body) ||
FileMessageContent(:final body) => body, AudioMessageContent(:final body) ||
_ => "", FileMessageContent(:final body) => body,
}; _ => "",
},
);
state = ComposerDraft( state = ComposerDraft(
text: text, text: text,
textCommands: commands,
relatedEvent: rowId, relatedEvent: rowId,
relationType: .edit, relationType: .edit,
beforeEdit: beforeEdit:

View file

@ -6,6 +6,7 @@ import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/text_command.dart";
part "composer_draft.freezed.dart"; part "composer_draft.freezed.dart";
@ -14,6 +15,7 @@ class const ComposerDraft({
// Formatted like ":id#name#", see the composer's tagTextFormatter // Formatted like ":id#name#", see the composer's tagTextFormatter
final String text = "", final String text = "",
final IList<Tag> tags = const .empty(), final IList<Tag> tags = const .empty(),
final IList<TextCommand> textCommands = const .empty(),
final int? relatedEvent, final int? relatedEvent,
final RelationType relationType = .reply, final RelationType relationType = .reply,

View file

@ -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<TextCommand> addTo(IList<TextCommand> commands) => .new(
commands
.where(
(other) => other != this && (group == null || other.group != group),
)
.toIList()
.add(this)
.sortedBy((command) => command.index),
);
static String prefixOf(IList<TextCommand> commands) =>
commands.map((command) => "${command.prefix} ").join();
static ({IList<TextCommand> commands, String text}) parse(
String text, [
IList<TextCommand> 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),
};
}

View file

@ -8,13 +8,16 @@ import "package:nexus/controllers/bot_argument_suggestions.dart";
import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_argument_suggestion.dart";
import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command.dart";
import "package:nexus/models/text_command.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
class const BotCommandPanel( class const BotCommandPanel(
final ComposerParams composer, { final ComposerParams composer, {
required final IList<BotCommand> commands, required final IList<BotCommand> commands,
required final IList<TextCommand> textCommands,
required final TextEditingController controller, required final TextEditingController controller,
required final void Function(BotCommand command) onPick, required final void Function(BotCommand command) onPick,
required final void Function(TextCommand command) onPickText,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -49,13 +52,27 @@ class const BotCommandPanel(
); );
final children = switch (ref.watch(draftProvider)) { final children = switch (ref.watch(draftProvider)) {
null => null => [
commands ...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)) .where((command) => command.completes(text.value))
.map( .map(
(command) => commandTile(command, onTap: () => onPick(command)), (command) => commandTile(command, onTap: () => onPick(command)),
) ),
.toList(), ],
final draft => [ final draft => [
commandTile(draft.command), commandTile(draft.command),
if (draft.focusedParameter case final parameter?) if (draft.focusedParameter case final parameter?)

View file

@ -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.dart";
import "package:nexus/models/bot_command_draft.dart"; import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/composer_params.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_form.dart";
import "package:nexus/widgets/composer/bot_command_panel.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart";
import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/widgets/emoji_picker.dart";
@ -90,6 +91,7 @@ class const Composer(
:sticker, :sticker,
:poll, :poll,
:location, :location,
:textCommands,
) = ref.watch( ) = ref.watch(
draftProvider.select( draftProvider.select(
(draft) => ( (draft) => (
@ -99,6 +101,7 @@ class const Composer(
sticker: draft.sticker, sticker: draft.sticker,
poll: draft.poll, poll: draft.poll,
location: draft.location, location: draft.location,
textCommands: draft.textCommands,
), ),
), ),
); );
@ -269,11 +272,57 @@ class const Composer(
return false; 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<BotCommand>.empty() ? const IList<BotCommand>.empty()
: ref.watch(BotCommandsController.provider(roomId)).value ?? : ref.watch(BotCommandsController.provider(roomId)).value ??
const .empty(); const .empty();
final availableTextCommands = hasReplacement || attachment != null
? const IList<TextCommand>.empty()
: TextCommand.values
.where((command) => !textCommands.contains(command))
.toIList();
void setTextCommands(IList<TextCommand> 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) { void pickBotCommand(BotCommand command) {
ref ref
.read(botCommandProvider.notifier) .read(botCommandProvider.notifier)
@ -290,7 +339,8 @@ class const Composer(
[] => null, [] => null,
}; };
void pickTypedBotCommand(String text) { void pickTypedCommand(String text) {
if (pickTypedTextCommands(text)) return;
if (botCommands.any((command) => command.prefix.startsWith(text))) return; if (botCommands.any((command) => command.prefix.startsWith(text))) return;
if (typedBotCommand(text) case final command?) pickBotCommand(command); if (typedBotCommand(text) case final command?) pickBotCommand(command);
} }
@ -352,10 +402,19 @@ class const Composer(
return; return;
} }
if (typedBotCommand(controller.text) ?? // In the order the command panel shows them
botCommands.firstWhereOrNull( if (typedBotCommand(controller.text) case final command?) {
(command) => command.completes(controller.text), 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?) { case final command?) {
return pickBotCommand(command); return pickBotCommand(command);
} }
@ -365,13 +424,14 @@ class const Composer(
return; return;
} }
onSend( onSend(
controller.formattedText, "${TextCommand.prefixOf(textCommands)}${controller.formattedText}",
shouldMention: shouldMention.value, shouldMention: shouldMention.value,
tags: .new(controller.tags), tags: .new(controller.tags),
); );
// Before dismissing, which restores the draft from before an edit // Before dismissing, which restores the draft from before an edit
controller.text = ""; controller.text = "";
setTextCommands(const .empty());
dismiss(); dismiss();
} }
@ -386,6 +446,25 @@ class const Composer(
return .ignored; 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( final style = TextStyle(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
fontWeight: .bold, fontWeight: .bold,
@ -419,8 +498,10 @@ class const Composer(
child: BotCommandPanel( child: BotCommandPanel(
composer, composer,
commands: botCommands, commands: botCommands,
textCommands: availableTextCommands,
controller: controller, controller: controller,
onPick: pickBotCommand, onPick: pickBotCommand,
onPickText: pickTextCommand,
), ),
), ),
ClipRRect( ClipRRect(
@ -731,7 +812,7 @@ class const Composer(
":": style, ":": style,
}, },
builder: (context, key) => Focus( builder: (context, key) => Focus(
onKeyEvent: sendOnEnter, onKeyEvent: onTextFieldKeyEvent,
// Overrides the keyboard shortcut for pasting // Overrides the keyboard shortcut for pasting
child: Actions( child: Actions(
actions: { actions: {
@ -739,60 +820,85 @@ class const Composer(
pasteFiles, pasteFiles,
), ),
}, },
child: TextField( child: Row(
maxLines: 12, spacing: 4,
minLines: 1, children: [
textCapitalization: .sentences, ...textCommands.map(
onChanged: pickTypedBotCommand, (command) => InputChip(
autofocus: label: Text(command.prefix),
(Platform.isLinux || tooltip: command.description,
Platform.isMacOS || visualDensity: .compact,
Platform.isWindows), materialTapTargetSize:
decoration: .new( .shrinkWrap,
hintText: "Your message here...", onDeleted: () =>
border: .none, removeTextCommand(command),
),
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( Expanded(
onContentInserted: (content) { child: TextField(
if (attachment != null) return; 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 if (content.data
case final data?) { case final data?) {
attachBytes( attachBytes(
data, data,
"image.${content.mimeType.split("/").last}", "image.${content.mimeType.split("/").last}",
).onError(showError); ).onError(showError);
} }
}, },
), ),
),
),
],
), ),
), ),
), ),

View file

@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.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/extensions/get_link_info.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/helpers/mxc_image.dart";
@ -65,6 +66,20 @@ class const Html(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId), 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" => "a" =>
element.attributes["href"]?.linkInfo == null element.attributes["href"]?.linkInfo == null
? null ? null