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/controllers/rooms.dart";
|
||||||
import "package:nexus/helpers/extensions/is_media.dart";
|
import "package:nexus/helpers/extensions/is_media.dart";
|
||||||
import "package:nexus/helpers/extensions/send_failed.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/content.dart";
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
import "package:nexus/models/content/poll_end.dart";
|
import "package:nexus/models/content/poll_end.dart";
|
||||||
|
|
@ -354,6 +356,7 @@ class RoomChatController(final ChatParams info)
|
||||||
Content content, {
|
Content content, {
|
||||||
bool shouldMention = true,
|
bool shouldMention = true,
|
||||||
int? repliedEventRowId,
|
int? repliedEventRowId,
|
||||||
|
IList<String> mentionedUserIds = const .empty(),
|
||||||
}) async {
|
}) async {
|
||||||
final repliedEvent = repliedEventRowId == null
|
final repliedEvent = repliedEventRowId == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -372,6 +375,7 @@ class RoomChatController(final ChatParams info)
|
||||||
content: content,
|
content: content,
|
||||||
mentions: Mentions(
|
mentions: Mentions(
|
||||||
userIds: [
|
userIds: [
|
||||||
|
...mentionedUserIds,
|
||||||
if (shouldMention && repliedEvent != null) repliedEvent.sender,
|
if (shouldMention && repliedEvent != null) repliedEvent.sender,
|
||||||
].toIList(),
|
].toIList(),
|
||||||
),
|
),
|
||||||
|
|
@ -430,6 +434,17 @@ class RoomChatController(final ChatParams info)
|
||||||
repliedEventRowId: repliedEventRowId,
|
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.
|
/// Completes once the event has come down sync.
|
||||||
Future<void> sendPollReference(
|
Future<void> sendPollReference(
|
||||||
Event poll,
|
Event poll,
|
||||||
|
|
|
||||||
|
|
@ -173,9 +173,13 @@ extension BuildEventOptions on Event {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (content is MessageContent &&
|
if (sender == userId &&
|
||||||
content is! LocationMessageContent &&
|
switch (content) {
|
||||||
sender == userId)
|
LocationMessageContent() ||
|
||||||
|
TextMessageContent(botCommand: _?) => false,
|
||||||
|
MessageContent() => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
onTap: () => onRelation(rowId, .edit),
|
onTap: () => onRelation(rowId, .edit),
|
||||||
child: const ListTile(leading: Icon(Icons.edit), title: Text("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:collection/collection.dart";
|
||||||
import "package:nexus/models/content/avatar.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/canonical_alias.dart";
|
||||||
import "package:nexus/models/content/create.dart";
|
import "package:nexus/models/content/create.dart";
|
||||||
import "package:nexus/models/content/encryption.dart";
|
import "package:nexus/models/content/encryption.dart";
|
||||||
|
|
@ -75,5 +76,9 @@ enum EventType(
|
||||||
PollResponseContent.fromJson,
|
PollResponseContent.fromJson,
|
||||||
),
|
),
|
||||||
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
|
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
|
||||||
|
botCommandDescription(
|
||||||
|
"org.matrix.msc4391.command_description",
|
||||||
|
BotCommandDescriptionContent.fromJson,
|
||||||
|
),
|
||||||
message("m.room.message", MessageContent.fromJson),
|
message("m.room.message", MessageContent.fromJson),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
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/info/audio.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/info/file.dart";
|
import "package:nexus/models/info/file.dart";
|
||||||
|
|
@ -24,6 +25,7 @@ sealed class MessageContent extends Content with _$MessageContent {
|
||||||
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
|
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
|
||||||
MessageFormat? format,
|
MessageFormat? format,
|
||||||
String? formattedBody,
|
String? formattedBody,
|
||||||
|
@JsonKey(name: "org.matrix.msc4391.command") BotCommandInput? botCommand,
|
||||||
}) = TextMessageContent;
|
}) = TextMessageContent;
|
||||||
|
|
||||||
@FreezedUnionValue("m.notice")
|
@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 "dart:io";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
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/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/widgets/emoji_picker.dart";
|
||||||
import "package:nexus/helpers/extensions/get_filename.dart";
|
import "package:nexus/helpers/extensions/get_filename.dart";
|
||||||
import "package:nexus/helpers/extensions/is_media.dart";
|
import "package:nexus/helpers/extensions/is_media.dart";
|
||||||
|
|
@ -66,6 +75,7 @@ class const Composer(
|
||||||
required bool shouldMention,
|
required bool shouldMention,
|
||||||
})
|
})
|
||||||
onSendLocation,
|
onSendLocation,
|
||||||
|
required final Future<void> Function(BotCommandDraft draft) onSendBotCommand,
|
||||||
final FocusNode? node,
|
final FocusNode? node,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -122,6 +132,8 @@ class const Composer(
|
||||||
|
|
||||||
final poll = useState<PollStart?>(null);
|
final poll = useState<PollStart?>(null);
|
||||||
final location = useState<LocationMessageContent?>(null);
|
final location = useState<LocationMessageContent?>(null);
|
||||||
|
final botCommandProvider = BotCommandDraftController.provider(roomId);
|
||||||
|
final botCommand = ref.watch(botCommandProvider);
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
|
|
@ -130,7 +142,10 @@ class const Composer(
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
final hasReplacement =
|
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
|
// The relation type stays as edit after cancelling an edit
|
||||||
final isEditing = relationType == .edit && relatedEvent != null;
|
final isEditing = relationType == .edit && relatedEvent != null;
|
||||||
|
|
@ -144,6 +159,7 @@ class const Composer(
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
location.value = null;
|
location.value = null;
|
||||||
|
Future.microtask(ref.read(botCommandProvider.notifier).clear);
|
||||||
|
|
||||||
final content = relatedEvent.content;
|
final content = relatedEvent.content;
|
||||||
final text =
|
final text =
|
||||||
|
|
@ -282,24 +298,93 @@ class const Composer(
|
||||||
return false;
|
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() {
|
void send() {
|
||||||
if (hasReplacement) {
|
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)?) {
|
if (sticker.value case (final shortcode, final image)?) {
|
||||||
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
||||||
} else if (poll.value case final draft?) {
|
} else if (poll.value case final draft?) {
|
||||||
onSendPoll(draft, shouldMention: shouldMention.value);
|
onSendPoll(draft, shouldMention: shouldMention.value);
|
||||||
} else if (location.value case final draft?) {
|
} else if (location.value case final draft?) {
|
||||||
onSendLocation(draft, shouldMention: shouldMention.value);
|
onSendLocation(draft, shouldMention: shouldMention.value);
|
||||||
|
} else if (botCommand != null) {
|
||||||
|
onSendBotCommand(botCommand);
|
||||||
}
|
}
|
||||||
onDismiss();
|
onDismiss();
|
||||||
// The draft is kept, so it can still be sent after the replacement
|
// The draft is kept, so it can still be sent after the replacement
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
location.value = null;
|
location.value = null;
|
||||||
node?.requestFocus();
|
ref.read(botCommandProvider.notifier).clear();
|
||||||
|
// Once the text field is back
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => node?.requestFocus(),
|
||||||
|
);
|
||||||
return;
|
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 ||
|
if (controller.value.text.isEmpty && attachment == null ||
|
||||||
attachment != null && attachment.$2 == null) {
|
attachment != null && attachment.$2 == null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -353,6 +438,16 @@ class const Composer(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: Durations.short4,
|
||||||
|
alignment: .bottomCenter,
|
||||||
|
child: BotCommandPanel(
|
||||||
|
roomId,
|
||||||
|
commands: botCommands,
|
||||||
|
controller: controller,
|
||||||
|
onPick: pickBotCommand,
|
||||||
|
),
|
||||||
|
),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: .all(.circular(12)),
|
borderRadius: .all(.circular(12)),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -519,30 +614,32 @@ class const Composer(
|
||||||
sticker.value,
|
sticker.value,
|
||||||
poll.value,
|
poll.value,
|
||||||
location.value,
|
location.value,
|
||||||
|
botCommand,
|
||||||
)) {
|
)) {
|
||||||
(_, final draft?, _) => ComposerReplacement(
|
(_, final draft?, _, _) =>
|
||||||
removeTooltip: "Remove poll",
|
ComposerReplacement(
|
||||||
onRemove: () {
|
removeTooltip: "Remove poll",
|
||||||
poll.value = null;
|
onRemove: () {
|
||||||
node?.requestFocus();
|
poll.value = null;
|
||||||
},
|
node?.requestFocus();
|
||||||
onKeyEvent: sendOnEnter,
|
},
|
||||||
ListTile(
|
onKeyEvent: sendOnEnter,
|
||||||
leading: Icon(
|
ListTile(
|
||||||
Icons.poll,
|
leading: Icon(
|
||||||
color: theme.colorScheme.primary,
|
Icons.poll,
|
||||||
),
|
color: theme.colorScheme.primary,
|
||||||
title: Text(draft.question),
|
),
|
||||||
subtitle: Text(
|
title: Text(draft.question),
|
||||||
draft.answers
|
subtitle: Text(
|
||||||
.map((answer) => answer.text)
|
draft.answers
|
||||||
.join(" · "),
|
.map((answer) => answer.text)
|
||||||
maxLines: 1,
|
.join(" · "),
|
||||||
overflow: .ellipsis,
|
maxLines: 1,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
((final shortcode, final image)?, _, _, _) =>
|
||||||
((final shortcode, final image)?, _, _) =>
|
|
||||||
ComposerReplacement(
|
ComposerReplacement(
|
||||||
removeTooltip: "Remove sticker",
|
removeTooltip: "Remove sticker",
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
|
|
@ -561,35 +658,50 @@ class const Composer(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(_, _, final draft?) => ComposerReplacement(
|
(_, _, final draft?, _) =>
|
||||||
removeTooltip: "Remove location",
|
ComposerReplacement(
|
||||||
onRemove: () {
|
removeTooltip: "Remove location",
|
||||||
location.value = null;
|
onRemove: () {
|
||||||
node?.requestFocus();
|
location.value = null;
|
||||||
},
|
node?.requestFocus();
|
||||||
onKeyEvent: sendOnEnter,
|
},
|
||||||
ListTile(
|
onKeyEvent: sendOnEnter,
|
||||||
leading: Icon(
|
ListTile(
|
||||||
draft.assetType == .pin
|
leading: Icon(
|
||||||
? Icons.location_pin
|
draft.assetType == .pin
|
||||||
: Icons.my_location,
|
? Icons.location_pin
|
||||||
color: theme.colorScheme.primary,
|
: Icons.my_location,
|
||||||
),
|
color: theme.colorScheme.primary,
|
||||||
title: Text(
|
),
|
||||||
draft.description ??
|
title: Text(
|
||||||
(draft.assetType == .pin
|
draft.description ??
|
||||||
? "Location"
|
(draft.assetType == .pin
|
||||||
: "My location"),
|
? "Location"
|
||||||
),
|
: "My location"),
|
||||||
subtitle: Text(
|
),
|
||||||
draft.point?.formatted ??
|
subtitle: Text(
|
||||||
draft.geoUri.toString(),
|
draft.point?.formatted ??
|
||||||
maxLines: 1,
|
draft.geoUri.toString(),
|
||||||
overflow: .ellipsis,
|
maxLines: 1,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
(_, _, _, final draft?) =>
|
||||||
(null, null, null) => FlutterTagger(
|
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,
|
triggerStrategy: .eager,
|
||||||
// Characters that continue a search, for
|
// Characters that continue a search, for
|
||||||
// e.g. :+1, :custom_emoji or @user:server
|
// e.g. :+1, :custom_emoji or @user:server
|
||||||
|
|
@ -627,6 +739,7 @@ class const Composer(
|
||||||
maxLines: 12,
|
maxLines: 12,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
textCapitalization: .sentences,
|
textCapitalization: .sentences,
|
||||||
|
onChanged: pickTypedBotCommand,
|
||||||
autofocus:
|
autofocus:
|
||||||
(Platform.isLinux ||
|
(Platform.isLinux ||
|
||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
|
|
|
||||||
|
|
@ -164,9 +164,11 @@ class const EventRenderer(
|
||||||
: switch (event.content) {
|
: switch (event.content) {
|
||||||
Content(:final parseError?, :final stackTrace) => Row(
|
Content(:final parseError?, :final stackTrace) => Row(
|
||||||
children: [
|
children: [
|
||||||
ErrorDialog(
|
Flexible(
|
||||||
"An error occurred while parsing event ${event.eventId}:\n$parseError",
|
child: ErrorDialog(
|
||||||
stackTrace,
|
"An error occurred while parsing event ${event.eventId}:\n$parseError",
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,12 @@ final class const RoomChat({
|
||||||
repliedEventRowId,
|
repliedEventRowId,
|
||||||
)
|
)
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
|
onSendBotCommand: (draft) => notifier
|
||||||
|
.sendBotCommand(
|
||||||
|
draft,
|
||||||
|
repliedEventRowId: repliedEventRowId,
|
||||||
|
)
|
||||||
|
.onError(showError),
|
||||||
relationType: relationType.value,
|
relationType: relationType.value,
|
||||||
relatedEvent: relatedEvent.value,
|
relatedEvent: relatedEvent.value,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue