parent
29840d595a
commit
dbc42b0ec2
6 changed files with 267 additions and 75 deletions
|
|
@ -9,6 +9,7 @@ import "package:nexus/models/composer_draft.dart";
|
|||
import "package:nexus/models/composer_params.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/models/text_command.dart";
|
||||
|
||||
class ComposerDraftController(final ComposerParams composer)
|
||||
extends Notifier<ComposerDraft> {
|
||||
|
|
@ -35,7 +36,8 @@ class ComposerDraftController(final ComposerParams composer)
|
|||
.read(RoomsController.provider)[composer.roomId]
|
||||
?.withLatestEdit(rowId);
|
||||
final content = event?.content;
|
||||
final text =
|
||||
// Edit sources start with text commands, like "/me "
|
||||
final (:commands, :text) = TextCommand.parse(
|
||||
event?.localContent?.editSource ??
|
||||
switch (content) {
|
||||
TextMessageContent(:final body) ||
|
||||
|
|
@ -44,10 +46,12 @@ class ComposerDraftController(final ComposerParams composer)
|
|||
AudioMessageContent(:final body) ||
|
||||
FileMessageContent(:final body) => body,
|
||||
_ => "",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
state = ComposerDraft(
|
||||
text: text,
|
||||
textCommands: commands,
|
||||
relatedEvent: rowId,
|
||||
relationType: .edit,
|
||||
beforeEdit:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import "package:nexus/models/content/image_pack.dart";
|
|||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/models/text_command.dart";
|
||||
|
||||
part "composer_draft.freezed.dart";
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ class const ComposerDraft({
|
|||
// Formatted like ":id#name#", see the composer's tagTextFormatter
|
||||
final String text = "",
|
||||
final IList<Tag> tags = const .empty(),
|
||||
final IList<TextCommand> textCommands = const .empty(),
|
||||
final int? relatedEvent,
|
||||
final RelationType relationType = .reply,
|
||||
|
||||
|
|
|
|||
48
lib/models/text_command.dart
Normal file
48
lib/models/text_command.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
|
||||
enum TextCommandGroup { messageType, format }
|
||||
|
||||
// Prefixes gomuks parses out of sent text, in the order it expects them
|
||||
enum TextCommand(final String description, {final TextCommandGroup? group}) {
|
||||
me("Send as an emote", group: .messageType),
|
||||
notice("Send as a notice", group: .messageType),
|
||||
unencrypted("Send unencrypted, even if the room is encrypted"),
|
||||
rawinputbody("Use the text as the body as-is, rather than from the HTML"),
|
||||
plain("Send without any formatting", group: .format),
|
||||
html("Send with only HTML, without markdown", group: .format),
|
||||
htmlmd("Send with both HTML and markdown", group: .format),
|
||||
rainbow("Send in rainbow colors, with markdown", group: .format);
|
||||
|
||||
String get prefix => "/$name";
|
||||
|
||||
bool completes(String text) =>
|
||||
text.startsWith("/") && prefix.startsWith(text.toLowerCase());
|
||||
|
||||
// Replaces any command from the same group, as gomuks only uses one
|
||||
IList<TextCommand> addTo(IList<TextCommand> commands) => .new(
|
||||
commands
|
||||
.where(
|
||||
(other) => other != this && (group == null || other.group != group),
|
||||
)
|
||||
.toIList()
|
||||
.add(this)
|
||||
.sortedBy((command) => command.index),
|
||||
);
|
||||
|
||||
static String prefixOf(IList<TextCommand> commands) =>
|
||||
commands.map((command) => "${command.prefix} ").join();
|
||||
|
||||
static ({IList<TextCommand> commands, String text}) parse(
|
||||
String text, [
|
||||
IList<TextCommand> commands = const .empty(),
|
||||
]) => switch (values.firstWhereOrNull(
|
||||
(command) => text.toLowerCase().startsWith("${command.prefix} "),
|
||||
)) {
|
||||
final command? => parse(
|
||||
text.substring(command.prefix.length + 1),
|
||||
command.addTo(commands),
|
||||
),
|
||||
null => (commands: commands, text: text),
|
||||
};
|
||||
}
|
||||
|
|
@ -8,13 +8,16 @@ 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/models/text_command.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
|
||||
class const BotCommandPanel(
|
||||
final ComposerParams composer, {
|
||||
required final IList<BotCommand> commands,
|
||||
required final IList<TextCommand> textCommands,
|
||||
required final TextEditingController controller,
|
||||
required final void Function(BotCommand command) onPick,
|
||||
required final void Function(TextCommand command) onPickText,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
|
|
@ -49,13 +52,27 @@ class const BotCommandPanel(
|
|||
);
|
||||
|
||||
final children = switch (ref.watch(draftProvider)) {
|
||||
null =>
|
||||
commands
|
||||
null => [
|
||||
...textCommands
|
||||
.where((command) => command.completes(text.value))
|
||||
.map(
|
||||
(command) => ListTile(
|
||||
leading: AvatarOrHash(null, "gomuks", dimension: 32),
|
||||
title: Text(command.prefix),
|
||||
subtitle: Text(
|
||||
"gomuks · ${command.description}",
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => onPickText(command),
|
||||
),
|
||||
),
|
||||
...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?)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import "package:nexus/helpers/extensions/with_latest_edit.dart";
|
|||
import "package:nexus/models/bot_command.dart";
|
||||
import "package:nexus/models/bot_command_draft.dart";
|
||||
import "package:nexus/models/composer_params.dart";
|
||||
import "package:nexus/models/text_command.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";
|
||||
|
|
@ -90,6 +91,7 @@ class const Composer(
|
|||
:sticker,
|
||||
:poll,
|
||||
:location,
|
||||
:textCommands,
|
||||
) = ref.watch(
|
||||
draftProvider.select(
|
||||
(draft) => (
|
||||
|
|
@ -99,6 +101,7 @@ class const Composer(
|
|||
sticker: draft.sticker,
|
||||
poll: draft.poll,
|
||||
location: draft.location,
|
||||
textCommands: draft.textCommands,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -269,11 +272,57 @@ class const Composer(
|
|||
return false;
|
||||
}
|
||||
|
||||
final botCommands = isEditing || hasReplacement || attachment != null
|
||||
// Bot commands can't follow text commands, which gomuks sends as text
|
||||
final botCommands =
|
||||
isEditing ||
|
||||
hasReplacement ||
|
||||
attachment != null ||
|
||||
textCommands.isNotEmpty
|
||||
? const IList<BotCommand>.empty()
|
||||
: ref.watch(BotCommandsController.provider(roomId)).value ??
|
||||
const .empty();
|
||||
|
||||
final availableTextCommands = hasReplacement || attachment != null
|
||||
? const IList<TextCommand>.empty()
|
||||
: TextCommand.values
|
||||
.where((command) => !textCommands.contains(command))
|
||||
.toIList();
|
||||
|
||||
void setTextCommands(IList<TextCommand> commands) =>
|
||||
draftNotifier.update((draft) => draft.copyWith(textCommands: commands));
|
||||
|
||||
void pickTextCommand(TextCommand command) {
|
||||
setTextCommands(command.addTo(textCommands));
|
||||
controller.text = "";
|
||||
node?.requestFocus();
|
||||
}
|
||||
|
||||
void removeTextCommand(TextCommand command) {
|
||||
setTextCommands(textCommands.remove(command));
|
||||
node?.requestFocus();
|
||||
}
|
||||
|
||||
/// Turns text commands typed at the start, like "/me ", into chips.
|
||||
/// Returns whether there were any.
|
||||
bool pickTypedTextCommands(String text) {
|
||||
if (availableTextCommands.isEmpty) return false;
|
||||
final parsed = TextCommand.parse(text, textCommands);
|
||||
if (parsed.text == text) return false;
|
||||
|
||||
setTextCommands(parsed.commands);
|
||||
final removed = text.length - parsed.text.length;
|
||||
controller.value = .new(
|
||||
text: parsed.text,
|
||||
selection: .collapsed(
|
||||
offset: (controller.selection.baseOffset - removed).clamp(
|
||||
0,
|
||||
parsed.text.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
void pickBotCommand(BotCommand command) {
|
||||
ref
|
||||
.read(botCommandProvider.notifier)
|
||||
|
|
@ -290,7 +339,8 @@ class const Composer(
|
|||
[] => null,
|
||||
};
|
||||
|
||||
void pickTypedBotCommand(String text) {
|
||||
void pickTypedCommand(String text) {
|
||||
if (pickTypedTextCommands(text)) return;
|
||||
if (botCommands.any((command) => command.prefix.startsWith(text))) return;
|
||||
if (typedBotCommand(text) case final command?) pickBotCommand(command);
|
||||
}
|
||||
|
|
@ -352,8 +402,17 @@ class const Composer(
|
|||
return;
|
||||
}
|
||||
|
||||
if (typedBotCommand(controller.text) ??
|
||||
botCommands.firstWhereOrNull(
|
||||
// In the order the command panel shows them
|
||||
if (typedBotCommand(controller.text) case final command?) {
|
||||
return pickBotCommand(command);
|
||||
}
|
||||
if (availableTextCommands.firstWhereOrNull(
|
||||
(command) => command.completes(controller.text),
|
||||
)
|
||||
case final command?) {
|
||||
return pickTextCommand(command);
|
||||
}
|
||||
if (botCommands.firstWhereOrNull(
|
||||
(command) => command.completes(controller.text),
|
||||
)
|
||||
case final command?) {
|
||||
|
|
@ -365,13 +424,14 @@ class const Composer(
|
|||
return;
|
||||
}
|
||||
onSend(
|
||||
controller.formattedText,
|
||||
"${TextCommand.prefixOf(textCommands)}${controller.formattedText}",
|
||||
shouldMention: shouldMention.value,
|
||||
tags: .new(controller.tags),
|
||||
);
|
||||
|
||||
// Before dismissing, which restores the draft from before an edit
|
||||
controller.text = "";
|
||||
setTextCommands(const .empty());
|
||||
dismiss();
|
||||
}
|
||||
|
||||
|
|
@ -386,6 +446,25 @@ class const Composer(
|
|||
return .ignored;
|
||||
}
|
||||
|
||||
KeyEventResult onTextFieldKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (textCommands.lastOrNull case final command?
|
||||
when event is KeyDownEvent &&
|
||||
event.logicalKey == .backspace &&
|
||||
controller.selection.isCollapsed &&
|
||||
controller.selection.baseOffset == 0) {
|
||||
setTextCommands(textCommands.removeLast());
|
||||
if (controller.text.isEmpty) {
|
||||
controller.value = .new(
|
||||
text: command.prefix,
|
||||
selection: .collapsed(offset: command.prefix.length),
|
||||
);
|
||||
}
|
||||
return .handled;
|
||||
}
|
||||
|
||||
return sendOnEnter(node, event);
|
||||
}
|
||||
|
||||
final style = TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: .bold,
|
||||
|
|
@ -419,8 +498,10 @@ class const Composer(
|
|||
child: BotCommandPanel(
|
||||
composer,
|
||||
commands: botCommands,
|
||||
textCommands: availableTextCommands,
|
||||
controller: controller,
|
||||
onPick: pickBotCommand,
|
||||
onPickText: pickTextCommand,
|
||||
),
|
||||
),
|
||||
ClipRRect(
|
||||
|
|
@ -731,7 +812,7 @@ class const Composer(
|
|||
":": style,
|
||||
},
|
||||
builder: (context, key) => Focus(
|
||||
onKeyEvent: sendOnEnter,
|
||||
onKeyEvent: onTextFieldKeyEvent,
|
||||
// Overrides the keyboard shortcut for pasting
|
||||
child: Actions(
|
||||
actions: {
|
||||
|
|
@ -739,32 +820,52 @@ class const Composer(
|
|||
pasteFiles,
|
||||
),
|
||||
},
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
...textCommands.map(
|
||||
(command) => InputChip(
|
||||
label: Text(command.prefix),
|
||||
tooltip: command.description,
|
||||
visualDensity: .compact,
|
||||
materialTapTargetSize:
|
||||
.shrinkWrap,
|
||||
onDeleted: () =>
|
||||
removeTextCommand(command),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
maxLines: 12,
|
||||
minLines: 1,
|
||||
textCapitalization: .sentences,
|
||||
onChanged: pickTypedBotCommand,
|
||||
onChanged: pickTypedCommand,
|
||||
autofocus:
|
||||
(Platform.isLinux ||
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows),
|
||||
decoration: .new(
|
||||
hintText: "Your message here...",
|
||||
hintText:
|
||||
"Your message here...",
|
||||
border: .none,
|
||||
),
|
||||
controller: controller,
|
||||
key: key,
|
||||
focusNode: node,
|
||||
// The toolbar's paste button doesn't use the shortcut's action, so replace it too
|
||||
contextMenuBuilder: (_, editableText) =>
|
||||
AdaptiveTextSelectionToolbar.buttonItems(
|
||||
contextMenuBuilder:
|
||||
(
|
||||
_,
|
||||
editableText,
|
||||
) => AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableText
|
||||
.contextMenuAnchors,
|
||||
buttonItems: editableText
|
||||
.contextMenuButtonItems
|
||||
.map(
|
||||
(item) =>
|
||||
item.type == .paste
|
||||
item.type ==
|
||||
.paste
|
||||
? item.copyWith(
|
||||
onPressed: () async {
|
||||
editableText
|
||||
|
|
@ -782,7 +883,9 @@ class const Composer(
|
|||
// Images pasted from Android keyboards' clipboards are inserted as content
|
||||
contentInsertionConfiguration: .new(
|
||||
onContentInserted: (content) {
|
||||
if (attachment != null) return;
|
||||
if (attachment != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.data
|
||||
case final data?) {
|
||||
|
|
@ -795,6 +898,9 @@ class const Composer(
|
|||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
|
||||
import "package:html/dom.dart" as dom;
|
||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
||||
import "package:nexus/helpers/launch_helper.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
|
|
@ -65,6 +66,20 @@ class const Html(
|
|||
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
|
||||
),
|
||||
|
||||
"font" => InlineCustomWidget(
|
||||
child: Html(
|
||||
(dom.Element.tag("span")
|
||||
..attributes.addAll({
|
||||
"data-mx-color": ?element.attributes["color"],
|
||||
...element.attributes,
|
||||
})
|
||||
..innerHtml = element.innerHtml)
|
||||
.outerHtml,
|
||||
textStyle: textStyle,
|
||||
roomId: roomId,
|
||||
),
|
||||
),
|
||||
|
||||
"a" =>
|
||||
element.attributes["href"]?.linkInfo == null
|
||||
? null
|
||||
|
|
|
|||
Loading…
Reference in a new issue