diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index d225da8..be78dec 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -178,25 +178,8 @@ class ClientController extends AsyncNotifier { Future sendMessage(SendMessageRequest request) async => Event.fromJson(await _sendCommand("send_message", request.toJson())); - Future sendEvent(SendEventRequest request) async { - final json = request.toJson(); - final content = request.content.toJson(); - - return .fromJson( - await _sendCommand("send_event", { - ...json, - "content": { - ...content, - if (request.relatesTo != null) - "m.relates_to": { - ...((content["m.relates_to"] as Map?) ?? {}), - "event_id": request.relatesTo, - "rel_type": request.relationType, - }, - }, - }), - ); - } + Future sendEvent(SendEventRequest request) async => + Event.fromJson(await _sendCommand("send_event", request.toJson())); Future setState(SetStateRequest request) async => await _sendCommand("set_state", request.toJson()); diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 94fb7a3..bd4808d 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -340,7 +340,21 @@ class RoomChatController(final ChatParams info) ); } - Future sendSticker(String shortcode, PackImage image) async { + /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. + Future sendSticker( + String shortcode, + PackImage image, { + bool shouldMention = true, + int? repliedEventRowId, + }) async { + final repliedEvent = repliedEventRowId == null + ? null + : ref.read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.events[repliedEventRowId], + ), + ); + final event = await ref .read(ClientController.provider.notifier) .sendEvent( @@ -352,6 +366,12 @@ class RoomChatController(final ChatParams info) info: image.info ?? const .new(), url: image.url, ), + mentions: Mentions( + userIds: [ + if (shouldMention && repliedEvent != null) repliedEvent.sender, + ].toIList(), + ), + replyTo: repliedEvent?.eventId, ), ); diff --git a/lib/models/requests/send_event.dart b/lib/models/requests/send_event.dart index acad57f..c12a715 100644 --- a/lib/models/requests/send_event.dart +++ b/lib/models/requests/send_event.dart @@ -1,5 +1,6 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/send_message.dart"; part "send_event.freezed.dart"; part "send_event.g.dart"; @@ -10,12 +11,34 @@ class const SendEventRequest({ required final String roomId, required final String type, required final Content content, - final String? relatesTo, - final String? relationType, + // Merged into the content by toJson, as gomuks doesn't take these + @JsonKey(includeToJson: false) final String? relatesTo, + @JsonKey(includeToJson: false) final String? relationType, + @JsonKey(includeToJson: false) final String? replyTo, + @JsonKey(includeToJson: false) final Mentions? mentions, final bool synchronous = false, final bool disableEncryption = false, }) with _$SendEventRequest { - Map toJson() => _$SendEventRequestToJson(this); + Map toJson() { + final content = this.content.toJson(); + + return { + ..._$SendEventRequestToJson(this), + "content": { + ...content, + "m.mentions": ?mentions?.toJson(), + if (relatesTo != null || replyTo != null) + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + if (relatesTo != null) ...{ + "event_id": relatesTo, + "rel_type": relationType, + }, + if (replyTo != null) "m.in_reply_to": {"event_id": replyTo}, + }, + }, + }; + } factory SendEventRequest.fromJson(Map json) => _$SendEventRequestFromJson(json); diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 18eebbf..4baad68 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -21,6 +21,7 @@ import "package:nexus/controllers/image_packs.dart"; import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; @@ -42,7 +43,11 @@ class const Composer( required IList tags, }) onSend, - required final Future Function(String shortcode, PackImage image) + required final Future Function( + String shortcode, + PackImage image, { + required bool shouldMention, + }) onSendSticker, final FocusNode? node, super.key, @@ -87,9 +92,18 @@ class const Composer( final textBefore = useState(null); + /// The sticker to send, which replaces the text field until it is sent or + /// removed, as stickers can't have a caption + final sticker = useState<(String, PackImage)?>(null); + useEffect(() { + sticker.value = null; + return null; + }, [roomId]); + useEffect(() { if (relationType == .edit && relatedEvent != null) { textBefore.value ??= controller.text; + sticker.value = null; final text = relatedEvent.localContent?.editSource ?? @@ -202,6 +216,15 @@ class const Composer( } void send() { + if (sticker.value case (final shortcode, final image)?) { + onSendSticker(shortcode, image, shouldMention: shouldMention.value); + onDismiss(); + // The draft is kept, so it can still be sent after the sticker + sticker.value = null; + node?.requestFocus(); + return; + } + if (controller.value.text.isEmpty && attachment == null || attachment != null && attachment.$2 == null) { return; @@ -216,6 +239,17 @@ class const Composer( controller.text = ""; } + KeyEventResult sendOnEnter(FocusNode _, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + send(); + return .handled; + } + + return .ignored; + } + final style = TextStyle( color: theme.colorScheme.primary, fontWeight: .bold, @@ -268,40 +302,44 @@ class const Composer( ) ? [ IconButton( - onPressed: () => context.showAdaptiveSheet( - (context) => EmojiPicker( - roomId: roomId, - onSelection: - (value, {shortcode, description}) { - Navigator.of(context).pop(); - if (shortcode == null) { - controller.text += value; - } else { - final text = "${controller.text}:"; - controller.value = .new( - text: text, - selection: .collapsed( - offset: text.length, - ), - ); - controller.addTag( - id: ImagePack.emojiMarkdown( - shortcode, - value, - description: description, - ), - name: shortcode, - ); - } - node?.requestFocus(); - }, - ), - ), + onPressed: sticker.value != null + ? null + : () => context.showAdaptiveSheet( + (context) => EmojiPicker( + roomId: roomId, + onSelection: + (value, {shortcode, description}) { + Navigator.of(context).pop(); + if (shortcode == null) { + controller.text += value; + } else { + final text = + "${controller.text}:"; + controller.value = .new( + text: text, + selection: .collapsed( + offset: text.length, + ), + ); + controller.addTag( + id: ImagePack.emojiMarkdown( + shortcode, + value, + description: description, + ), + name: shortcode, + ); + } + node?.requestFocus(); + }, + ), + ), icon: Icon(Icons.emoji_emotions), ), AdaptiveMenuButton( tooltip: "Add media", - enabled: attachment == null, + enabled: + attachment == null && sticker.value == null, itemBuilder: (context) => [ for (final (source, title, icon) in [ if (Platform.isAndroid || Platform.isIOS) @@ -347,12 +385,14 @@ class const Composer( ), if (canSendStickers) PopupMenuItem( + // Stickers can't replace messages + enabled: relationType != .edit, onTap: () => context.showAdaptiveSheet( (context) => StickerPicker( roomId: roomId, onSelection: (shortcode, image) { Navigator.of(context).pop(); - onSendSticker(shortcode, image); + sticker.value = (shortcode, image); }, ), ), @@ -366,108 +406,129 @@ class const Composer( ), SizedBox(width: 8), Expanded( - child: FlutterTagger( - triggerStrategy: .eager, - // Characters that continue a search, for - // e.g. :+1, :custom_emoji or @user.name - searchRegex: .new(r"^[\w.+-]*$"), - overlay: TaggerOverlay( - query.value, - roomId: roomId, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.addTag(id: id, name: name); - node?.requestFocus(); - }, - ), - controller: controller, - onSearch: (newQuery, newTriggerCharacter) { - triggerCharacter.value = newTriggerCharacter; - query.value = newQuery; - }, - triggerCharacterAndStyles: { - "@": style, - "#": style, - ":": style, - }, - builder: (context, key) => Focus( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == - LogicalKeyboardKey.enter) { - final shiftPressed = HardwareKeyboard - .instance - .isShiftPressed; - - if (!shiftPressed) { - send(); - return .handled; - } - } - - return .ignored; - }, - // Overrides the keyboard shortcut for pasting - child: Actions( - actions: { - PasteTextIntent: PasteFilesAction( - pasteFiles, - ), - }, - child: TextField( - maxLines: 12, - minLines: 1, - textCapitalization: .sentences, - autofocus: - (Platform.isLinux || - Platform.isMacOS || - Platform.isWindows), - decoration: .new( - 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( - anchors: - editableText.contextMenuAnchors, - buttonItems: editableText - .contextMenuButtonItems - .map( - (item) => item.type == .paste - ? item.copyWith( - onPressed: () async { - editableText - .hideToolbar(); - if (!await pasteFiles()) { - item.onPressed - ?.call(); - } - }, - ) - : item, - ) - .toList(), + child: AnimatedSize( + duration: Durations.short4, + alignment: .bottomCenter, + child: switch (sticker.value) { + (final shortcode, final image)? => Focus( + autofocus: true, + onKeyEvent: sendOnEnter, + child: Padding( + padding: .symmetric(vertical: 8), + child: Row( + children: [ + ConstrainedBox( + constraints: .loose(.square(128)), + child: MxcEmoji( + image.url, + label: image.body ?? shortcode, + ), ), - // Images pasted from Android keyboards' clipboards are inserted as content - contentInsertionConfiguration: .new( - onContentInserted: (content) { - if (attachment != null) return; - - if (content.data case final data?) { - attachBytes( - data, - "image.${content.mimeType.split("/").last}", - ).onError(showError); - } - }, + Spacer(), + IconButton( + tooltip: "Remove sticker", + onPressed: () { + sticker.value = null; + node?.requestFocus(); + }, + icon: Icon(Icons.close), + ), + ], ), ), ), - ), + null => FlutterTagger( + triggerStrategy: .eager, + // Characters that continue a search, for + // e.g. :+1, :custom_emoji or @user.name + searchRegex: .new(r"^[\w.+-]*$"), + overlay: TaggerOverlay( + query.value, + roomId: roomId, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.addTag(id: id, name: name); + node?.requestFocus(); + }, + ), + controller: controller, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = + newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: { + "@": style, + "#": style, + ":": style, + }, + builder: (context, key) => Focus( + onKeyEvent: sendOnEnter, + // Overrides the keyboard shortcut for pasting + child: Actions( + actions: { + PasteTextIntent: PasteFilesAction( + pasteFiles, + ), + }, + child: TextField( + maxLines: 12, + minLines: 1, + textCapitalization: .sentences, + autofocus: + (Platform.isLinux || + Platform.isMacOS || + Platform.isWindows), + decoration: .new( + 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( + anchors: editableText + .contextMenuAnchors, + buttonItems: editableText + .contextMenuButtonItems + .map( + (item) => + item.type == .paste + ? item.copyWith( + onPressed: () async { + editableText + .hideToolbar(); + if (!await pasteFiles()) { + item.onPressed + ?.call(); + } + }, + ) + : item, + ) + .toList(), + ), + // Images pasted from Android keyboards' clipboards are inserted as content + contentInsertionConfiguration: .new( + onContentInserted: (content) { + if (attachment != null) return; + + if (content.data + case final data?) { + attachBytes( + data, + "image.${content.mimeType.split("/").last}", + ).onError(showError); + } + }, + ), + ), + ), + ), + ), + }, ), ), IconButton( diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index db7120c..bd83ada 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -323,9 +323,21 @@ final class const RoomChat({ relatedEvent.value, ) .onError(showError), - onSendSticker: (shortcode, image) => - notifier - .sendSticker(shortcode, image) + onSendSticker: + ( + shortcode, + image, { + required shouldMention, + }) => notifier + .sendSticker( + shortcode, + image, + shouldMention: shouldMention, + repliedEventRowId: + relationType.value == .reply + ? relatedEvent.value + : null, + ) .onError(showError), relationType: relationType.value, relatedEvent: relatedEvent.value,