Add MSC4391 support

Fixes #133
This commit is contained in:
Henry Hiles 2026-11-03 13:12:58 -05:00
commit 3fc33a5ac9
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 1236 additions and 55 deletions

View file

@ -0,0 +1,53 @@
import "package:collection/collection.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/widgets/composer/bot_option_pill.dart";
/// A bot command with its parameters filled in inline, like Discord's slash
/// commands.
class const BotCommandForm(
final BotCommandDraft draft, {
required final VoidCallback onSubmit,
/// Called on backspace in the first parameter while empty.
required final VoidCallback onRemove,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: .center,
children: [
Text(
draft.command.prefix,
style: .new(
color: Theme.of(context).colorScheme.primary,
fontWeight: .bold,
),
),
...draft.shown.mapIndexed(
(index, parameter) => BotOptionPill(
parameter,
key: ValueKey(parameter.key),
roomId: draft.roomId,
onSubmit: onSubmit,
onRemove: parameter.optional
? () => ref
.read(
BotCommandDraftController.provider(draft.roomId)
.notifier,
)
.removeOption(parameter.key)
: index == 0
? onRemove
: null,
),
),
],
);
}
}

View file

@ -0,0 +1,144 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/scheduler.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
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/widgets/avatar_or_hash.dart";
class const BotCommandPanel(
final String roomId, {
required final IList<BotCommand> commands,
required final TextEditingController controller,
required final void Function(BotCommand command) onPick,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final draftProvider = BotCommandDraftController.provider(roomId);
final suggestionsProvider = BotArgumentSuggestionsController.provider(
roomId,
);
final text = useState(controller.text);
useEffect(() {
void update() {
if (SchedulerBinding.instance.schedulerPhase == .persistentCallbacks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) text.value = controller.text;
});
} else {
text.value = controller.text;
}
}
controller.addListener(update);
return () => controller.removeListener(update);
}, [controller]);
ListTile commandTile(BotCommand command, {VoidCallback? onTap}) => .new(
leading: AvatarOrHash(command.botAvatar, command.botName, dimension: 32),
title: Text(command.prefix),
subtitle: Text(command.about, maxLines: 1, overflow: .ellipsis),
onTap: onTap,
);
final children = switch (ref.watch(draftProvider)) {
null =>
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?)
ListTile(
title: Text(
parameter.key,
style: .new(color: theme.colorScheme.primary),
),
subtitle: switch ((
draft.errors[parameter.key],
parameter.description,
)) {
(final error?, _) => Text(
error,
style: .new(color: theme.colorScheme.error),
),
(null, final description?) => Text(description),
(null, null) => null,
},
trailing: Text(
parameter.schema.label,
style: theme.textTheme.labelSmall,
),
),
...ref
.watch(suggestionsProvider)
.map(
(suggestion) => ListTile(
leading: switch (suggestion) {
BotArgumentSuggestion(room: _?) => AvatarOrHash(
suggestion.avatar,
suggestion.title,
fallback: Icon(Icons.numbers),
),
BotArgumentSuggestion(subtitle: _?) => AvatarOrHash(
suggestion.avatar,
suggestion.title,
),
_ => null,
},
title: Text(suggestion.title),
subtitle: switch (suggestion.subtitle) {
final subtitle? => Text(subtitle),
null => null,
},
onTap: () =>
ref.read(suggestionsProvider.notifier).pick(suggestion),
),
),
if (draft.hiddenOptions.isNotEmpty)
Padding(
padding: EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: .center,
children: [
Text("Optional", style: theme.textTheme.labelMedium),
...draft.hiddenOptions.map(
(option) => ActionChip(
avatar: Icon(Icons.add),
label: Text(option.key),
tooltip: option.description,
onPressed: () =>
ref.read(draftProvider.notifier).addOption(option.key),
),
),
],
),
),
],
};
return children.isEmpty
? SizedBox(width: double.infinity)
: TextFieldTapRegion(
child: Card(
margin: .only(bottom: 8),
clipBehavior: .antiAlias,
child: ConstrainedBox(
constraints: .new(maxHeight: 320),
child: ListView(shrinkWrap: true, children: children),
),
),
);
}
}

View file

@ -0,0 +1,123 @@
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/models/content/bot_command_description.dart";
class const BotOptionPill(
final BotParameter parameter, {
required final String roomId,
/// Called on backspace while empty, to remove the parameter or command.
final VoidCallback? onRemove,
required final VoidCallback onSubmit,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final provider = BotCommandDraftController.provider(roomId);
final (:text, :isFocused, :error, :isLast) = ref.watch(
provider.select(
(draft) => (
text: draft?.texts[parameter.key] ?? "",
isFocused: draft?.focused == parameter.key,
error: draft?.errors[parameter.key],
isLast:
draft?.missing.every((other) => other.key == parameter.key) ??
true,
),
),
);
final controller = useTextEditingController(text: text);
final focusNode = useFocusNode();
useEffect(() {
if (controller.text != text) {
controller.value = .new(
text: text,
selection: .collapsed(offset: text.length),
);
}
return null;
}, [text]);
useEffect(() {
if (isFocused && !focusNode.hasFocus) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
}
return null;
}, [isFocused]);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (focused) {
if (focused) {
ref.read(provider.notifier).focus(parameter.key);
}
},
onKeyEvent: (_, event) {
if (onRemove case final remove?
when event is KeyDownEvent &&
event.logicalKey == .backspace &&
controller.text.isEmpty) {
remove();
return .handled;
}
return .ignored;
},
child: Container(
padding: .symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFocused ? colorScheme.primaryContainer : colorScheme.surface,
border: error == null ? null : .all(color: colorScheme.error),
borderRadius: .all(.circular(8)),
),
child: Row(
mainAxisSize: .min,
spacing: 4,
children: [
Text(
"${parameter.key}:",
style: .new(
fontWeight: .bold,
color: isFocused
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
Flexible(
child: ConstrainedBox(
constraints: .new(minWidth: 16, maxWidth: 320),
child: IntrinsicWidth(
child: TextField(
controller: controller,
focusNode: focusNode,
readOnly: parameter.schema is LiteralSchema,
onChanged: (text) => ref
.read(provider.notifier)
.setText(parameter.key, text),
textInputAction: isLast ? .send : .next,
onSubmitted: (_) => onSubmit(),
// Keeps the keyboard open, rather than moving focus itself
onEditingComplete: () {},
keyboardType:
parameter.schema.allows(.integer) &&
!parameter.schema.allows(.string)
? .number
: null,
decoration: .collapsed(hintText: null),
),
),
),
),
],
),
),
);
}
}

View file

@ -1,7 +1,16 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:collection/collection.dart";
import "package:nexus/controllers/bot_argument_suggestions.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/controllers/bot_commands.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_error_snack_bar.dart";
import "package:nexus/models/bot_command.dart";
import "package:nexus/models/bot_command_draft.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";
import "package:nexus/helpers/extensions/get_filename.dart";
import "package:nexus/helpers/extensions/is_media.dart";
@ -66,6 +75,7 @@ class const Composer(
required bool shouldMention,
})
onSendLocation,
required final Future<void> Function(BotCommandDraft draft) onSendBotCommand,
final FocusNode? node,
super.key,
}) extends HookConsumerWidget {
@ -122,6 +132,8 @@ class const Composer(
final poll = useState<PollStart?>(null);
final location = useState<LocationMessageContent?>(null);
final botCommandProvider = BotCommandDraftController.provider(roomId);
final botCommand = ref.watch(botCommandProvider);
useEffect(() {
sticker.value = null;
poll.value = null;
@ -130,7 +142,10 @@ class const Composer(
}, [roomId]);
final hasReplacement =
sticker.value != null || poll.value != null || location.value != null;
sticker.value != null ||
poll.value != null ||
location.value != null ||
botCommand != null;
// The relation type stays as edit after cancelling an edit
final isEditing = relationType == .edit && relatedEvent != null;
@ -144,6 +159,7 @@ class const Composer(
sticker.value = null;
poll.value = null;
location.value = null;
Future.microtask(ref.read(botCommandProvider.notifier).clear);
final content = relatedEvent.content;
final text =
@ -282,24 +298,93 @@ class const Composer(
return false;
}
final botCommands = isEditing || hasReplacement || attachment != null
? const IList<BotCommand>.empty()
: ref.watch(BotCommandsController.provider(roomId));
void pickBotCommand(BotCommand command) {
ref
.read(botCommandProvider.notifier)
.start(command, text: command.argumentsIn(controller.text) ?? "");
controller.text = "";
}
BotCommand? typedBotCommand(String text) => switch (botCommands
.where((command) => command.argumentsIn(text) != null)
.sortedBy<num>((command) => -command.prefix.length)) {
[final command, final other, ...] when command.prefix == other.prefix =>
null,
[final command, ...] => command,
[] => null,
};
void pickTypedBotCommand(String text) {
if (botCommands.any((command) => command.prefix.startsWith(text))) return;
if (typedBotCommand(text) case final command?) pickBotCommand(command);
}
void removeBotCommand({required bool keepText}) {
if (botCommand?.command.prefix case final prefix? when keepText) {
controller.value = .new(
text: prefix,
selection: .collapsed(offset: prefix.length),
);
}
ref.read(botCommandProvider.notifier).clear();
WidgetsBinding.instance.addPostFrameCallback((_) => node?.requestFocus());
}
void send() {
if (hasReplacement) {
if (botCommand != null) {
if (ref
.read(BotArgumentSuggestionsController.provider(roomId).notifier)
.pickTop()) {
return;
}
if (botCommand.missing.firstOrNull case final next?
when next.key != botCommand.focused &&
!botCommand.errors.containsKey(botCommand.focused)) {
ref.read(botCommandProvider.notifier).focus(next.key);
return;
}
if (botCommand.problem case final problem?) {
ScaffoldMessenger.of(context).showErrorSnackBar(problem);
ref.read(botCommandProvider.notifier).focusNext();
return;
}
}
if (sticker.value case (final shortcode, final image)?) {
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
} else if (poll.value case final draft?) {
onSendPoll(draft, shouldMention: shouldMention.value);
} else if (location.value case final draft?) {
onSendLocation(draft, shouldMention: shouldMention.value);
} else if (botCommand != null) {
onSendBotCommand(botCommand);
}
onDismiss();
// The draft is kept, so it can still be sent after the replacement
sticker.value = null;
poll.value = null;
location.value = null;
node?.requestFocus();
ref.read(botCommandProvider.notifier).clear();
// Once the text field is back
WidgetsBinding.instance.addPostFrameCallback(
(_) => node?.requestFocus(),
);
return;
}
if (typedBotCommand(controller.text) ??
botCommands.firstWhereOrNull(
(command) => command.completes(controller.text),
)
case final command?) {
return pickBotCommand(command);
}
if (controller.value.text.isEmpty && attachment == null ||
attachment != null && attachment.$2 == null) {
return;
@ -353,6 +438,16 @@ class const Composer(
),
),
),
AnimatedSize(
duration: Durations.short4,
alignment: .bottomCenter,
child: BotCommandPanel(
roomId,
commands: botCommands,
controller: controller,
onPick: pickBotCommand,
),
),
ClipRRect(
borderRadius: .all(.circular(12)),
child: Column(
@ -519,30 +614,32 @@ class const Composer(
sticker.value,
poll.value,
location.value,
botCommand,
)) {
(_, final draft?, _) => ComposerReplacement(
removeTooltip: "Remove poll",
onRemove: () {
poll.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
ListTile(
leading: Icon(
Icons.poll,
color: theme.colorScheme.primary,
),
title: Text(draft.question),
subtitle: Text(
draft.answers
.map((answer) => answer.text)
.join(" · "),
maxLines: 1,
overflow: .ellipsis,
(_, final draft?, _, _) =>
ComposerReplacement(
removeTooltip: "Remove poll",
onRemove: () {
poll.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
ListTile(
leading: Icon(
Icons.poll,
color: theme.colorScheme.primary,
),
title: Text(draft.question),
subtitle: Text(
draft.answers
.map((answer) => answer.text)
.join(" · "),
maxLines: 1,
overflow: .ellipsis,
),
),
),
),
((final shortcode, final image)?, _, _) =>
((final shortcode, final image)?, _, _, _) =>
ComposerReplacement(
removeTooltip: "Remove sticker",
onRemove: () {
@ -561,35 +658,50 @@ class const Composer(
),
),
),
(_, _, final draft?) => ComposerReplacement(
removeTooltip: "Remove location",
onRemove: () {
location.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
ListTile(
leading: Icon(
draft.assetType == .pin
? Icons.location_pin
: Icons.my_location,
color: theme.colorScheme.primary,
),
title: Text(
draft.description ??
(draft.assetType == .pin
? "Location"
: "My location"),
),
subtitle: Text(
draft.point?.formatted ??
draft.geoUri.toString(),
maxLines: 1,
overflow: .ellipsis,
(_, _, final draft?, _) =>
ComposerReplacement(
removeTooltip: "Remove location",
onRemove: () {
location.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
ListTile(
leading: Icon(
draft.assetType == .pin
? Icons.location_pin
: Icons.my_location,
color: theme.colorScheme.primary,
),
title: Text(
draft.description ??
(draft.assetType == .pin
? "Location"
: "My location"),
),
subtitle: Text(
draft.point?.formatted ??
draft.geoUri.toString(),
maxLines: 1,
overflow: .ellipsis,
),
),
),
),
(null, null, null) => FlutterTagger(
(_, _, _, final draft?) =>
ComposerReplacement(
removeTooltip: "Remove command",
onRemove: () =>
removeBotCommand(keepText: false),
onKeyEvent: sendOnEnter,
BotCommandForm(
draft,
key: ValueKey(draft.command),
onSubmit: send,
onRemove: () =>
removeBotCommand(keepText: true),
),
),
(null, null, null, null) => FlutterTagger(
triggerStrategy: .eager,
// Characters that continue a search, for
// e.g. :+1, :custom_emoji or @user:server
@ -627,6 +739,7 @@ class const Composer(
maxLines: 12,
minLines: 1,
textCapitalization: .sentences,
onChanged: pickTypedBotCommand,
autofocus:
(Platform.isLinux ||
Platform.isMacOS ||

View file

@ -164,9 +164,11 @@ class const EventRenderer(
: switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row(
children: [
ErrorDialog(
"An error occurred while parsing event ${event.eventId}:\n$parseError",
stackTrace,
Flexible(
child: ErrorDialog(
"An error occurred while parsing event ${event.eventId}:\n$parseError",
stackTrace,
),
),
],
),

View file

@ -360,6 +360,12 @@ final class const RoomChat({
repliedEventRowId,
)
.onError(showError),
onSendBotCommand: (draft) => notifier
.sendBotCommand(
draft,
repliedEventRowId: repliedEventRowId,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {