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
18 changed files with 1236 additions and 55 deletions

View file

@ -0,0 +1,132 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/bot_argument_suggestion.dart";
import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/content/bot_command_description.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart";
class BotArgumentSuggestionsController(final String roomId)
extends Notifier<IList<BotArgumentSuggestion>> {
@override
IList<BotArgumentSuggestion> build() {
final draft = ref.watch(BotCommandDraftController.provider(roomId));
final (parameter, text) = switch (draft) {
BotCommandDraft(focusedParameter: final parameter?, :final texts) => (
parameter,
texts[parameter.key] ?? "",
),
_ => (null, ""),
};
if (parameter == null) return const .empty();
final schema = parameter.schema;
final parts = schema is ArraySchema
? text.split(BotParameterSchema.separator)
: [text];
final query = parts.last.trim().toLowerCase();
final picked = parts.take(parts.length - 1).toISet();
final members = schema.allows(.userId)
? ref
.watch(
MembersByStatusController.provider(
.new(roomId: roomId, status: .join),
),
)
.value ??
const .empty()
: const ISet<Event>.empty();
final rooms = schema.allows(.roomId)
? ref.watch(RoomsController.provider).values
: const <Room>[];
return {
...schema.literals.map((literal) => "$literal"),
if (schema.allows(.boolean)) ...["True", "False"],
}
.map((value) => BotArgumentSuggestion(title: value, text: value))
.followedBy(
members
.map(
(member) => switch (member) {
Event(
stateKey: final userId?,
content: MembershipContent(
:final displayName,
:final avatarUrl,
),
) =>
BotArgumentSuggestion(
title: displayName ?? userId.localpart,
subtitle: userId,
avatar: avatarUrl,
text: userId,
),
_ => null,
},
)
.nonNulls,
)
.followedBy(
rooms
.map(
(room) => switch (room.metadata) {
final metadata? => BotArgumentSuggestion(
title:
metadata.name ?? metadata.canonicalAlias ?? metadata.id,
subtitle: metadata.canonicalAlias ?? metadata.id,
avatar: metadata.avatar,
text: metadata.id,
room: room,
),
null => null,
},
)
.nonNulls,
)
.where(
(suggestion) =>
!picked.contains(suggestion.text) &&
[
suggestion.title,
suggestion.subtitle,
].any((text) => text?.toLowerCase().contains(query) == true),
)
.take(8)
.toIList();
}
void pick(BotArgumentSuggestion suggestion) => ref
.read(BotCommandDraftController.provider(roomId).notifier)
.pick(
suggestion,
via: switch (suggestion.room) {
final room? => ref.read(ViaController.provider(room)),
null => const .empty(),
},
);
bool pickTop() {
final draft = ref.read(BotCommandDraftController.provider(roomId));
if (state.firstOrNull case final suggestion?
when draft != null && !draft.arguments.containsKey(draft.focused)) {
pick(suggestion);
return true;
}
return false;
}
static final provider = NotifierProvider.autoDispose
.family<
BotArgumentSuggestionsController,
IList<BotArgumentSuggestion>,
String
>(BotArgumentSuggestionsController.new);
}

View file

@ -0,0 +1,103 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/bot_argument_suggestion.dart";
import "package:nexus/models/bot_command.dart";
import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/content/bot_command_description.dart";
class BotCommandDraftController(final String roomId)
extends Notifier<BotCommandDraft?> {
@override
BotCommandDraft? build() => null;
void start(BotCommand command, {String text = ""}) {
state = BotCommandDraft(
command: command,
roomId: roomId,
texts: .fromEntries(
command.description.parameters
.where((parameter) => !parameter.optional)
.map(
(parameter) => switch (parameter.schema) {
LiteralSchema(:final value) => MapEntry(
parameter.key,
"$value",
),
_ => null,
},
)
.nonNulls,
),
);
focusNext();
if (state?.focused case final key? when text.isNotEmpty) setText(key, text);
}
void setText(String key, String text) {
if (state case final draft?) {
state = draft.copyWith(texts: draft.texts.add(key, text));
}
}
void focus(String key) => state = state?.copyWith(focused: key);
void focusNext() {
if (state case final draft?) {
state = draft.copyWith(
focused: draft.missing.firstOrNull?.key ?? draft.focused,
);
}
}
void addOption(String key) {
if (state case final draft?) {
state = draft.copyWith(options: draft.options.add(key), focused: key);
}
}
void removeOption(String key) {
if (state case final draft?) {
state = draft.copyWith(
options: draft.options.remove(key),
texts: draft.texts.remove(key),
// Focus moves back a parameter if none are missing, so it isn't lost
focused: draft.focused == key
? draft.missing.firstOrNull?.key ??
draft.shown
.takeWhile((parameter) => parameter.key != key)
.lastOrNull
?.key
: draft.focused,
);
}
}
void pick(BotArgumentSuggestion suggestion, {required ISet<String> via}) {
if (state case BotCommandDraft(
focusedParameter: final parameter?,
:final texts,
:final vias,
)) {
final text = texts[parameter.key] ?? "";
final isArray = parameter.schema is ArraySchema;
state = state?.copyWith(
texts: texts.add(
parameter.key,
isArray
? "${text.substring(0, text.lastIndexOf(BotParameterSchema.separator) + 1)}${suggestion.text}, "
: suggestion.text,
),
vias: via.isEmpty ? vias : vias.add(suggestion.text, via.toIList()),
);
if (!isArray) focusNext();
}
}
void clear() => state = null;
static final provider = NotifierProvider.autoDispose
.family<BotCommandDraftController, BotCommandDraft?, String>(
BotCommandDraftController.new,
);
}

View file

@ -0,0 +1,66 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/bot_command.dart";
import "package:nexus/models/content/bot_command_description.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
class BotCommandsController(final String roomId)
extends Notifier<IList<BotCommand>> {
@override
IList<BotCommand> build() => ref.watch(
RoomsController.provider.select((rooms) {
final room = rooms[roomId];
if (room == null) return const .empty();
Content? membership(String userId) =>
switch (room.state[EventType.membership.type]?[userId]) {
final rowId? => room.events[rowId]?.content,
null => null,
};
return (room.state[EventType.botCommandDescription.type]?.values ??
const [])
.map(
(rowId) => switch (room.events[rowId]) {
Event(
redactedBy: null,
:final sender,
content: final BotCommandDescriptionContent description,
)
when description.isValid &&
!BotCommand.builtIn.contains(
description.command.split(" ").first,
) =>
switch (membership(sender)) {
MembershipContent(
status: .join,
:final displayName,
:final avatarUrl,
) =>
BotCommand(
sender: sender,
botName: displayName ?? sender.localpart,
botAvatar: avatarUrl,
description: description,
),
_ => null,
},
_ => null,
},
)
.nonNulls
.sortedBy((command) => command.description.command)
.toIList();
}),
);
static final provider = NotifierProvider.autoDispose
.family<BotCommandsController, IList<BotCommand>, String>(
BotCommandsController.new,
);
}

View file

@ -10,6 +10,8 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/is_media.dart";
import "package:nexus/helpers/extensions/send_failed.dart";
import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/content/bot_command.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/poll_end.dart";
@ -354,6 +356,7 @@ class RoomChatController(final ChatParams info)
Content content, {
bool shouldMention = true,
int? repliedEventRowId,
IList<String> mentionedUserIds = const .empty(),
}) async {
final repliedEvent = repliedEventRowId == null
? null
@ -372,6 +375,7 @@ class RoomChatController(final ChatParams info)
content: content,
mentions: Mentions(
userIds: [
...mentionedUserIds,
if (shouldMention && repliedEvent != null) repliedEvent.sender,
].toIList(),
),
@ -430,6 +434,17 @@ class RoomChatController(final ChatParams info)
repliedEventRowId: repliedEventRowId,
);
Future<void> sendBotCommand(
BotCommandDraft draft, {
int? repliedEventRowId,
}) => sendWithReply(
.message,
BotCommandMessageContent(body: draft.body, command: draft.input),
shouldMention: false,
mentionedUserIds: [draft.command.sender].lock,
repliedEventRowId: repliedEventRowId,
);
/// Completes once the event has come down sync.
Future<void> sendPollReference(
Event poll,

View file

@ -173,9 +173,13 @@ extension BuildEventOptions on Event {
),
),
if (content is MessageContent &&
content is! LocationMessageContent &&
sender == userId)
if (sender == userId &&
switch (content) {
LocationMessageContent() ||
TextMessageContent(botCommand: _?) => false,
MessageContent() => true,
_ => false,
})
PopupMenuItem(
onTap: () => onRelation(rowId, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),

View file

@ -0,0 +1,13 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
part "bot_argument_suggestion.freezed.dart";
@Freezed(toJson: false, fromJson: false)
class const BotArgumentSuggestion({
required final String title,
final String? subtitle,
final Uri? avatar,
required final String text,
final Room? room,
}) with _$BotArgumentSuggestion;

View file

@ -0,0 +1,36 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/bot_command_description.dart";
part "bot_command.freezed.dart";
@Freezed(toJson: false, fromJson: false)
class const BotCommand({
required final String sender,
required final String botName,
final Uri? botAvatar,
required final BotCommandDescriptionContent description,
}) with _$BotCommand {
/// Commands gomuks handles itself, which bots can't override.
static const builtIn = ISetConst({
"me",
"notice",
"timestamp",
"unencrypted",
"rawinputbody",
"rainbow",
"plain",
"html",
"htmlmd",
});
String get prefix => "/${description.command}";
String get about => [botName, ?description.description].join(" · ");
bool completes(String text) =>
text.startsWith("/") &&
(prefix.startsWith(text) || argumentsIn(text) != null);
String? argumentsIn(String text) =>
text.startsWith("$prefix ") ? text.substring(prefix.length + 1) : null;
}

View file

@ -0,0 +1,102 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/bot_command.dart";
import "package:nexus/models/content/bot_command.dart";
import "package:nexus/models/content/bot_command_description.dart";
part "bot_command_draft.freezed.dart";
@Freezed(toJson: false, fromJson: false)
class const BotCommandDraft({
required final BotCommand command,
required final String roomId,
final IMap<String, String> texts = const .empty(),
// The servers to join rooms picked from suggestions through, by room ID
final IMap<String, IList<String>> vias = const .empty(),
final ISet<String> options = const .empty(),
final String? focused,
}) with _$BotCommandDraft {
IList<BotParameter> get shown => command.description.parameters
.where(
(parameter) => !parameter.optional || options.contains(parameter.key),
)
.toIList();
IList<BotParameter> get hiddenOptions => command.description.parameters
.where(
(parameter) => parameter.optional && !options.contains(parameter.key),
)
.toIList();
BotParameter? get focusedParameter => command.description.parameters
.firstWhereOrNull((parameter) => parameter.key == focused);
({Object? value, String? error}) parse(BotParameter parameter) =>
parameter.schema.parseText(texts[parameter.key] ?? "", roomId: roomId);
IMap<String, Object> get arguments => .fromEntries(
shown
.map(
(parameter) => switch (parse(parameter).value) {
final value? => MapEntry(parameter.key, withVias(value)),
null => null,
},
)
.nonNulls,
);
IMap<String, String> get errors => .fromEntries(
shown
.map(
(parameter) => switch (parse(parameter).error) {
final error? => MapEntry(parameter.key, error),
null => null,
},
)
.nonNulls,
);
IList<BotParameter> get missing => shown
.where(
(parameter) => !parameter.optional && parse(parameter).value == null,
)
.toIList();
String? get problem => errors.isNotEmpty
? errors.entries.map((error) => "${error.key}: ${error.value}").join("\n")
: missing.isNotEmpty
? "Fill in ${missing.map((parameter) => parameter.key).join(", ")} first."
: null;
BotCommandInput get input =>
.new(command: command.description.command, arguments: arguments);
String get body => [
command.prefix,
...shown
.map(
(parameter) => switch (texts[parameter.key]
?.split(BotParameterSchema.separator)
.where((part) => part.isNotEmpty)
.join(" ")) {
final text? when text.isEmpty => null,
final text? when parameter.optional => "--${parameter.key} $text",
final text => text,
},
)
.nonNulls,
].join(" ");
Object withVias(Object value) => switch (value) {
List() => value.whereType<Object>().map(withVias).toList(),
final Map<String, Object> room && {"type": "room_id", "id": final String id}
when !room.containsKey("via") =>
switch (vias[id]) {
final via? => {...room, "via": via.unlock},
null => room,
},
_ => value,
};
}

View file

@ -0,0 +1,33 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "bot_command.freezed.dart";
part "bot_command.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const BotCommandMessageContent({
required final String body,
@JsonKey(name: "org.matrix.msc4391.command")
required final BotCommandInput command,
final String msgtype = "m.text",
}) extends Content with _$BotCommandMessageContent {
@override
Map<String, Object?> toJson() => _$BotCommandMessageContentToJson(this);
factory BotCommandMessageContent.fromJson(Map<String, Object?> json) =>
_$BotCommandMessageContentFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const BotCommandInput({
required final String command,
final IMap<String, Object> arguments = const .empty(),
}) with _$BotCommandInput {
Map<String, Object?> toJson() => _$BotCommandInputToJson(this);
factory BotCommandInput.fromJson(Map<String, Object?> json) =>
_$BotCommandInputFromJson(json);
}

View file

@ -0,0 +1,229 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/models/content/content.dart";
part "bot_command_description.freezed.dart";
part "bot_command_description.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const BotCommandDescriptionContent({
required final String command,
final IList<BotParameter> parameters = const .empty(),
@JsonKey(fromJson: BotCommandDescriptionContent.plainText)
final String? description,
}) extends Content with _$BotCommandDescriptionContent {
// The plain text from an extensible events m.text block
static String? plainText(Map<String, dynamic>? json) =>
switch (json?["m.text"]) {
final List representations =>
representations
.map(
(representation) => switch (representation) {
{"mimetype": final String mimetype}
when mimetype != "text/plain" =>
null,
{"body": final String body} => body,
_ => null,
},
)
.nonNulls
.firstOrNull,
_ => null,
};
bool get isValid =>
command.trim().isNotEmpty &&
parameters.map((parameter) => parameter.key).toISet().length ==
parameters.length &&
parameters.every((parameter) => parameter.schema.isValid());
@override
Map<String, Object?> toJson() => _$BotCommandDescriptionContentToJson(this);
factory BotCommandDescriptionContent.fromJson(Map<String, Object?> json) =>
_$BotCommandDescriptionContentFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const BotParameter({
required final String key,
required final BotParameterSchema schema,
final bool optional = false,
@JsonKey(fromJson: BotCommandDescriptionContent.plainText)
final String? description,
}) with _$BotParameter {
Map<String, Object?> toJson() => _$BotParameterToJson(this);
factory BotParameter.fromJson(Map<String, Object?> json) =>
_$BotParameterFromJson(json);
}
@Freezed(unionKey: "schema_type")
sealed class BotParameterSchema with _$BotParameterSchema {
const BotParameterSchema._();
static final separator = RegExp(r"[\s,]+");
const factory BotParameterSchema.primitive({required BotPrimitiveType type}) =
PrimitiveSchema;
const factory BotParameterSchema.literal({required Object value}) =
LiteralSchema;
const factory BotParameterSchema.union({
required IList<BotParameterSchema> variants,
}) = UnionSchema;
const factory BotParameterSchema.array({required BotParameterSchema items}) =
ArraySchema;
bool isValid({BotParameterSchema? parent}) => switch (this) {
PrimitiveSchema() => true,
LiteralSchema(:final value) =>
value is String || value is int || value is bool,
UnionSchema(:final variants) =>
(parent == null || parent is ArraySchema) &&
variants.isNotEmpty &&
variants.every(
(variant) =>
variant is! UnionSchema && variant.isValid(parent: this),
),
ArraySchema(:final items) => parent == null && items.isValid(parent: this),
};
String get label => switch (this) {
PrimitiveSchema(:final type) => type.label,
LiteralSchema(:final value) => "$value",
UnionSchema(:final variants) =>
variants.map((variant) => variant.label).join(" | "),
ArraySchema(:final items) => "${items.label}...",
};
String get expected => switch (this) {
PrimitiveSchema(:final type) => type.expected,
LiteralSchema(:final value) => '"$value"',
UnionSchema(:final variants) =>
variants.map((variant) => variant.expected).join(" or "),
ArraySchema(:final items) => items.expected,
};
bool allows(BotPrimitiveType type) => switch (this) {
PrimitiveSchema(type: final allowed) => allowed == type,
LiteralSchema() => false,
UnionSchema(:final variants) => variants.any(
(variant) => variant.allows(type),
),
ArraySchema(:final items) => items.allows(type),
};
IList<Object> get literals => switch (this) {
PrimitiveSchema() => const .empty(),
LiteralSchema(:final value) => [value].lock,
UnionSchema(:final variants) =>
variants.expand((variant) => variant.literals).toIList(),
ArraySchema(:final items) => items.literals,
};
Object? parse(String text, {required String roomId}) => switch (this) {
PrimitiveSchema(:final type) => type.parse(text, roomId: roomId),
LiteralSchema(:final value) => "$value" == text ? value : null,
UnionSchema(:final variants) =>
variants
.map((variant) => variant.parse(text, roomId: roomId))
.nonNulls
.firstOrNull,
ArraySchema(:final items) => items.parse(text, roomId: roomId),
};
({Object? value, String? error}) parseText(
String text, {
required String roomId,
}) {
final parts = (this is ArraySchema ? text.split(separator) : [text.trim()])
.where((part) => part.isNotEmpty)
.map((part) => (part, parse(part, roomId: roomId)))
.toIList();
return switch (parts.firstWhereOrNull((part) => part.$2 == null)) {
(final part, _) => (value: null, error: '"$part" isn\'t $expected.'),
null when parts.isEmpty => (value: null, error: null),
null when this is ArraySchema => (
value: parts.map((part) => part.$2).nonNulls.toList(),
error: null,
),
null => (value: parts.first.$2, error: null),
};
}
factory BotParameterSchema.fromJson(Map<String, Object?> json) =>
_$BotParameterSchemaFromJson(json);
}
enum BotPrimitiveType(final String label, final String expected) {
@JsonValue("string")
string("text", "text"),
@JsonValue("integer")
integer("number", "a whole number"),
@JsonValue("boolean")
boolean("yes/no", "yes or no"),
@JsonValue("user_id")
userId("user", "a user ID like @alice:example.org"),
@JsonValue("server_name")
serverName("server", "a server name like example.org"),
@JsonValue("room_alias")
roomAlias("room alias", "a room alias like #room:example.org"),
@JsonValue("room_id")
roomId("room", "a room ID or link like !abc:example.org"),
@JsonValue("event_id")
eventId("event", r"a message link or event ID like $abc");
static final serverNamePattern = RegExp(
r"^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$",
);
Object? parse(String text, {required String roomId}) {
final (:identifier, :eventId, :via) =
text.linkInfo ??
(identifier: text, eventId: null, via: const IList<String>.empty());
return switch (this) {
.string => text,
// In the range canonical JSON allows
.integer => switch (int.tryParse(text, radix: 10)) {
final value? when value.abs() <= 9007199254740991 => value,
_ => null,
},
.boolean => switch (text.toLowerCase()) {
"true" || "yes" => true,
"false" || "no" => false,
_ => null,
},
.userId when identifier.startsWith("@") && identifier.contains(":") =>
identifier,
.roomAlias when identifier.startsWith("#") && identifier.contains(":") =>
identifier,
.serverName when serverNamePattern.hasMatch(text) => text,
.roomId when identifier.startsWith("!") && eventId == null => {
"type": "room_id",
"id": identifier,
if (via.isNotEmpty) "via": via.unlock,
},
.eventId when identifier.startsWith("!") && eventId != null => {
"type": "event_id",
"id": identifier,
"event_id": eventId,
if (via.isNotEmpty) "via": via.unlock,
},
.eventId when identifier.startsWith(r"$") => {
"type": "event_id",
"id": roomId,
"event_id": identifier,
},
_ => null,
};
}
}

View file

@ -1,5 +1,6 @@
import "package:collection/collection.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/bot_command_description.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
@ -75,5 +76,9 @@ enum EventType(
PollResponseContent.fromJson,
),
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
botCommandDescription(
"org.matrix.msc4391.command_description",
BotCommandDescriptionContent.fromJson,
),
message("m.room.message", MessageContent.fromJson),
}

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/bot_command.dart";
import "package:nexus/models/info/audio.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/info/file.dart";
@ -24,6 +25,7 @@ sealed class MessageContent extends Content with _$MessageContent {
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
MessageFormat? format,
String? formattedBody,
@JsonKey(name: "org.matrix.msc4391.command") BotCommandInput? botCommand,
}) = TextMessageContent;
@FreezedUnionValue("m.notice")

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,8 +614,10 @@ class const Composer(
sticker.value,
poll.value,
location.value,
botCommand,
)) {
(_, final draft?, _) => ComposerReplacement(
(_, final draft?, _, _) =>
ComposerReplacement(
removeTooltip: "Remove poll",
onRemove: () {
poll.value = null;
@ -542,7 +639,7 @@ class const Composer(
),
),
),
((final shortcode, final image)?, _, _) =>
((final shortcode, final image)?, _, _, _) =>
ComposerReplacement(
removeTooltip: "Remove sticker",
onRemove: () {
@ -561,7 +658,8 @@ class const Composer(
),
),
),
(_, _, final draft?) => ComposerReplacement(
(_, _, final draft?, _) =>
ComposerReplacement(
removeTooltip: "Remove location",
onRemove: () {
location.value = null;
@ -589,7 +687,21 @@ class const Composer(
),
),
),
(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,10 +164,12 @@ class const EventRenderer(
: switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row(
children: [
ErrorDialog(
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: () {