diff --git a/lib/controllers/bot_argument_suggestions.dart b/lib/controllers/bot_argument_suggestions.dart new file mode 100644 index 0000000..aaccf98 --- /dev/null +++ b/lib/controllers/bot_argument_suggestions.dart @@ -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> { + @override + IList 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.empty(); + final rooms = schema.allows(.roomId) + ? ref.watch(RoomsController.provider).values + : const []; + + 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, + String + >(BotArgumentSuggestionsController.new); +} diff --git a/lib/controllers/bot_command_draft.dart b/lib/controllers/bot_command_draft.dart new file mode 100644 index 0000000..efe5759 --- /dev/null +++ b/lib/controllers/bot_command_draft.dart @@ -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 { + @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 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.new, + ); +} diff --git a/lib/controllers/bot_commands.dart b/lib/controllers/bot_commands.dart new file mode 100644 index 0000000..0cccf11 --- /dev/null +++ b/lib/controllers/bot_commands.dart @@ -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> { + @override + IList 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, String>( + BotCommandsController.new, + ); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 8b2d64d..83301fd 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -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 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 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 sendPollReference( Event poll, diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 297dfa6..28a05ff 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -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")), diff --git a/lib/models/bot_argument_suggestion.dart b/lib/models/bot_argument_suggestion.dart new file mode 100644 index 0000000..4b27cce --- /dev/null +++ b/lib/models/bot_argument_suggestion.dart @@ -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; diff --git a/lib/models/bot_command.dart b/lib/models/bot_command.dart new file mode 100644 index 0000000..1c5b400 --- /dev/null +++ b/lib/models/bot_command.dart @@ -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; +} diff --git a/lib/models/bot_command_draft.dart b/lib/models/bot_command_draft.dart new file mode 100644 index 0000000..4118088 --- /dev/null +++ b/lib/models/bot_command_draft.dart @@ -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 texts = const .empty(), + + // The servers to join rooms picked from suggestions through, by room ID + final IMap> vias = const .empty(), + final ISet options = const .empty(), + final String? focused, +}) with _$BotCommandDraft { + IList get shown => command.description.parameters + .where( + (parameter) => !parameter.optional || options.contains(parameter.key), + ) + .toIList(); + + IList 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 get arguments => .fromEntries( + shown + .map( + (parameter) => switch (parse(parameter).value) { + final value? => MapEntry(parameter.key, withVias(value)), + null => null, + }, + ) + .nonNulls, + ); + + IMap get errors => .fromEntries( + shown + .map( + (parameter) => switch (parse(parameter).error) { + final error? => MapEntry(parameter.key, error), + null => null, + }, + ) + .nonNulls, + ); + + IList 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().map(withVias).toList(), + final Map room && {"type": "room_id", "id": final String id} + when !room.containsKey("via") => + switch (vias[id]) { + final via? => {...room, "via": via.unlock}, + null => room, + }, + _ => value, + }; +} diff --git a/lib/models/content/bot_command.dart b/lib/models/content/bot_command.dart new file mode 100644 index 0000000..b5ce39d --- /dev/null +++ b/lib/models/content/bot_command.dart @@ -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 toJson() => _$BotCommandMessageContentToJson(this); + + factory BotCommandMessageContent.fromJson(Map json) => + _$BotCommandMessageContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const BotCommandInput({ + required final String command, + final IMap arguments = const .empty(), +}) with _$BotCommandInput { + Map toJson() => _$BotCommandInputToJson(this); + + factory BotCommandInput.fromJson(Map json) => + _$BotCommandInputFromJson(json); +} diff --git a/lib/models/content/bot_command_description.dart b/lib/models/content/bot_command_description.dart new file mode 100644 index 0000000..497887e --- /dev/null +++ b/lib/models/content/bot_command_description.dart @@ -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 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? 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 toJson() => _$BotCommandDescriptionContentToJson(this); + + factory BotCommandDescriptionContent.fromJson(Map 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 toJson() => _$BotParameterToJson(this); + + factory BotParameter.fromJson(Map 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 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 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 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.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, + }; + } +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 72373a4..87ab912 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -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), } diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 67e3c8d..3e7c373 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -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") diff --git a/lib/widgets/composer/bot_command_form.dart b/lib/widgets/composer/bot_command_form.dart new file mode 100644 index 0000000..a7cc2aa --- /dev/null +++ b/lib/widgets/composer/bot_command_form.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/composer/bot_command_panel.dart b/lib/widgets/composer/bot_command_panel.dart new file mode 100644 index 0000000..5ffb40a --- /dev/null +++ b/lib/widgets/composer/bot_command_panel.dart @@ -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 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), + ), + ), + ); + } +} diff --git a/lib/widgets/composer/bot_option_pill.dart b/lib/widgets/composer/bot_option_pill.dart new file mode 100644 index 0000000..c805a47 --- /dev/null +++ b/lib/widgets/composer/bot_option_pill.dart @@ -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), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 507f602..d5b6602 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -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 Function(BotCommandDraft draft) onSendBotCommand, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -122,6 +132,8 @@ class const Composer( final poll = useState(null); final location = useState(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.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((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 || diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 52b72fc..b7278e1 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -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, + ), ), ], ), diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index a08f7fc..efb8c20 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -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: () {