parent
f35f715b81
commit
3fc33a5ac9
6 changed files with 1236 additions and 55 deletions
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,30 +614,32 @@ class const Composer(
|
|||
sticker.value,
|
||||
poll.value,
|
||||
location.value,
|
||||
botCommand,
|
||||
)) {
|
||||
(_, final draft?, _) => ComposerReplacement(
|
||||
removeTooltip: "Remove poll",
|
||||
onRemove: () {
|
||||
poll.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.poll,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(draft.question),
|
||||
subtitle: Text(
|
||||
draft.answers
|
||||
.map((answer) => answer.text)
|
||||
.join(" · "),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
(_, final draft?, _, _) =>
|
||||
ComposerReplacement(
|
||||
removeTooltip: "Remove poll",
|
||||
onRemove: () {
|
||||
poll.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.poll,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(draft.question),
|
||||
subtitle: Text(
|
||||
draft.answers
|
||||
.map((answer) => answer.text)
|
||||
.join(" · "),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
((final shortcode, final image)?, _, _) =>
|
||||
((final shortcode, final image)?, _, _, _) =>
|
||||
ComposerReplacement(
|
||||
removeTooltip: "Remove sticker",
|
||||
onRemove: () {
|
||||
|
|
@ -561,35 +658,50 @@ class const Composer(
|
|||
),
|
||||
),
|
||||
),
|
||||
(_, _, final draft?) => ComposerReplacement(
|
||||
removeTooltip: "Remove location",
|
||||
onRemove: () {
|
||||
location.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
draft.assetType == .pin
|
||||
? Icons.location_pin
|
||||
: Icons.my_location,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
draft.description ??
|
||||
(draft.assetType == .pin
|
||||
? "Location"
|
||||
: "My location"),
|
||||
),
|
||||
subtitle: Text(
|
||||
draft.point?.formatted ??
|
||||
draft.geoUri.toString(),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
(_, _, final draft?, _) =>
|
||||
ComposerReplacement(
|
||||
removeTooltip: "Remove location",
|
||||
onRemove: () {
|
||||
location.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
draft.assetType == .pin
|
||||
? Icons.location_pin
|
||||
: Icons.my_location,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
draft.description ??
|
||||
(draft.assetType == .pin
|
||||
? "Location"
|
||||
: "My location"),
|
||||
),
|
||||
subtitle: Text(
|
||||
draft.point?.formatted ??
|
||||
draft.geoUri.toString(),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
(null, null, null) => FlutterTagger(
|
||||
(_, _, _, final draft?) =>
|
||||
ComposerReplacement(
|
||||
removeTooltip: "Remove command",
|
||||
onRemove: () =>
|
||||
removeBotCommand(keepText: false),
|
||||
onKeyEvent: sendOnEnter,
|
||||
BotCommandForm(
|
||||
draft,
|
||||
key: ValueKey(draft.command),
|
||||
onSubmit: send,
|
||||
onRemove: () =>
|
||||
removeBotCommand(keepText: true),
|
||||
),
|
||||
),
|
||||
(null, null, null, null) => FlutterTagger(
|
||||
triggerStrategy: .eager,
|
||||
// Characters that continue a search, for
|
||||
// e.g. :+1, :custom_emoji or @user:server
|
||||
|
|
@ -627,6 +739,7 @@ class const Composer(
|
|||
maxLines: 12,
|
||||
minLines: 1,
|
||||
textCapitalization: .sentences,
|
||||
onChanged: pickTypedBotCommand,
|
||||
autofocus:
|
||||
(Platform.isLinux ||
|
||||
Platform.isMacOS ||
|
||||
|
|
|
|||
|
|
@ -164,9 +164,11 @@ class const EventRenderer(
|
|||
: switch (event.content) {
|
||||
Content(:final parseError?, :final stackTrace) => Row(
|
||||
children: [
|
||||
ErrorDialog(
|
||||
"An error occurred while parsing event ${event.eventId}:\n$parseError",
|
||||
stackTrace,
|
||||
Flexible(
|
||||
child: ErrorDialog(
|
||||
"An error occurred while parsing event ${event.eventId}:\n$parseError",
|
||||
stackTrace,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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