parent
f35f715b81
commit
3fc33a5ac9
18 changed files with 1236 additions and 55 deletions
132
lib/controllers/bot_argument_suggestions.dart
Normal file
132
lib/controllers/bot_argument_suggestions.dart
Normal 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);
|
||||
}
|
||||
103
lib/controllers/bot_command_draft.dart
Normal file
103
lib/controllers/bot_command_draft.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
66
lib/controllers/bot_commands.dart
Normal file
66
lib/controllers/bot_commands.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
13
lib/models/bot_argument_suggestion.dart
Normal file
13
lib/models/bot_argument_suggestion.dart
Normal 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;
|
||||
36
lib/models/bot_command.dart
Normal file
36
lib/models/bot_command.dart
Normal 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;
|
||||
}
|
||||
102
lib/models/bot_command_draft.dart
Normal file
102
lib/models/bot_command_draft.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
33
lib/models/content/bot_command.dart
Normal file
33
lib/models/content/bot_command.dart
Normal 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);
|
||||
}
|
||||
229
lib/models/content/bot_command_description.dart
Normal file
229
lib/models/content/bot_command_description.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
53
lib/widgets/composer/bot_command_form.dart
Normal file
53
lib/widgets/composer/bot_command_form.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
144
lib/widgets/composer/bot_command_panel.dart
Normal file
144
lib/widgets/composer/bot_command_panel.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/widgets/composer/bot_option_pill.dart
Normal file
123
lib/widgets/composer/bot_option_pill.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -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: () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue