stickers replace composer, instead of sending immediately

Fixes #153
This commit is contained in:
Henry Hiles 2026-11-01 12:39:14 -05:00
commit 7baf56adef
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 252 additions and 153 deletions

View file

@ -178,25 +178,8 @@ class ClientController extends AsyncNotifier<int> {
Future<Event> sendMessage(SendMessageRequest request) async => Future<Event> sendMessage(SendMessageRequest request) async =>
Event.fromJson(await _sendCommand("send_message", request.toJson())); Event.fromJson(await _sendCommand("send_message", request.toJson()));
Future<Event> sendEvent(SendEventRequest request) async { Future<Event> sendEvent(SendEventRequest request) async =>
final json = request.toJson(); Event.fromJson(await _sendCommand("send_event", 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<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
},
}),
);
}
Future<String?> setState(SetStateRequest request) async => Future<String?> setState(SetStateRequest request) async =>
await _sendCommand("set_state", request.toJson()); await _sendCommand("set_state", request.toJson());

View file

@ -340,7 +340,21 @@ class RoomChatController(final ChatParams info)
); );
} }
Future<void> sendSticker(String shortcode, PackImage image) async { /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set.
Future<void> 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 final event = await ref
.read(ClientController.provider.notifier) .read(ClientController.provider.notifier)
.sendEvent( .sendEvent(
@ -352,6 +366,12 @@ class RoomChatController(final ChatParams info)
info: image.info ?? const .new(), info: image.info ?? const .new(),
url: image.url, url: image.url,
), ),
mentions: Mentions(
userIds: [
if (shouldMention && repliedEvent != null) repliedEvent.sender,
].toIList(),
),
replyTo: repliedEvent?.eventId,
), ),
); );

View file

@ -1,5 +1,6 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/send_message.dart";
part "send_event.freezed.dart"; part "send_event.freezed.dart";
part "send_event.g.dart"; part "send_event.g.dart";
@ -10,12 +11,34 @@ class const SendEventRequest({
required final String roomId, required final String roomId,
required final String type, required final String type,
required final Content content, required final Content content,
final String? relatesTo, // Merged into the content by toJson, as gomuks doesn't take these
final String? relationType, @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 synchronous = false,
final bool disableEncryption = false, final bool disableEncryption = false,
}) with _$SendEventRequest { }) with _$SendEventRequest {
Map<String, Object?> toJson() => _$SendEventRequestToJson(this); Map<String, Object?> 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<String, dynamic>?) ?? {}),
if (relatesTo != null) ...{
"event_id": relatesTo,
"rel_type": relationType,
},
if (replyTo != null) "m.in_reply_to": {"event_id": replyTo},
},
},
};
}
factory SendEventRequest.fromJson(Map<String, Object?> json) => factory SendEventRequest.fromJson(Map<String, Object?> json) =>
_$SendEventRequestFromJson(json); _$SendEventRequestFromJson(json);

View file

@ -21,6 +21,7 @@ import "package:nexus/controllers/image_packs.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/image_pack.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/widgets/sticker_picker.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
@ -42,7 +43,11 @@ class const Composer(
required IList<Tag> tags, required IList<Tag> tags,
}) })
onSend, onSend,
required final Future<void> Function(String shortcode, PackImage image) required final Future<void> Function(
String shortcode,
PackImage image, {
required bool shouldMention,
})
onSendSticker, onSendSticker,
final FocusNode? node, final FocusNode? node,
super.key, super.key,
@ -87,9 +92,18 @@ class const Composer(
final textBefore = useState<String?>(null); final textBefore = useState<String?>(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(() { useEffect(() {
if (relationType == .edit && relatedEvent != null) { if (relationType == .edit && relatedEvent != null) {
textBefore.value ??= controller.text; textBefore.value ??= controller.text;
sticker.value = null;
final text = final text =
relatedEvent.localContent?.editSource ?? relatedEvent.localContent?.editSource ??
@ -202,6 +216,15 @@ class const Composer(
} }
void send() { 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 || if (controller.value.text.isEmpty && attachment == null ||
attachment != null && attachment.$2 == null) { attachment != null && attachment.$2 == null) {
return; return;
@ -216,6 +239,17 @@ class const Composer(
controller.text = ""; 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( final style = TextStyle(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
fontWeight: .bold, fontWeight: .bold,
@ -268,7 +302,9 @@ class const Composer(
) )
? [ ? [
IconButton( IconButton(
onPressed: () => context.showAdaptiveSheet( onPressed: sticker.value != null
? null
: () => context.showAdaptiveSheet(
(context) => EmojiPicker( (context) => EmojiPicker(
roomId: roomId, roomId: roomId,
onSelection: onSelection:
@ -277,7 +313,8 @@ class const Composer(
if (shortcode == null) { if (shortcode == null) {
controller.text += value; controller.text += value;
} else { } else {
final text = "${controller.text}:"; final text =
"${controller.text}:";
controller.value = .new( controller.value = .new(
text: text, text: text,
selection: .collapsed( selection: .collapsed(
@ -301,7 +338,8 @@ class const Composer(
), ),
AdaptiveMenuButton( AdaptiveMenuButton(
tooltip: "Add media", tooltip: "Add media",
enabled: attachment == null, enabled:
attachment == null && sticker.value == null,
itemBuilder: (context) => [ itemBuilder: (context) => [
for (final (source, title, icon) in [ for (final (source, title, icon) in [
if (Platform.isAndroid || Platform.isIOS) if (Platform.isAndroid || Platform.isIOS)
@ -347,12 +385,14 @@ class const Composer(
), ),
if (canSendStickers) if (canSendStickers)
PopupMenuItem( PopupMenuItem(
// Stickers can't replace messages
enabled: relationType != .edit,
onTap: () => context.showAdaptiveSheet( onTap: () => context.showAdaptiveSheet(
(context) => StickerPicker( (context) => StickerPicker(
roomId: roomId, roomId: roomId,
onSelection: (shortcode, image) { onSelection: (shortcode, image) {
Navigator.of(context).pop(); Navigator.of(context).pop();
onSendSticker(shortcode, image); sticker.value = (shortcode, image);
}, },
), ),
), ),
@ -366,7 +406,38 @@ class const Composer(
), ),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: FlutterTagger( 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,
),
),
Spacer(),
IconButton(
tooltip: "Remove sticker",
onPressed: () {
sticker.value = null;
node?.requestFocus();
},
icon: Icon(Icons.close),
),
],
),
),
),
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.name // e.g. :+1, :custom_emoji or @user.name
@ -382,7 +453,8 @@ class const Composer(
), ),
controller: controller, controller: controller,
onSearch: (newQuery, newTriggerCharacter) { onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter; triggerCharacter.value =
newTriggerCharacter;
query.value = newQuery; query.value = newQuery;
}, },
triggerCharacterAndStyles: { triggerCharacterAndStyles: {
@ -391,22 +463,7 @@ class const Composer(
":": style, ":": style,
}, },
builder: (context, key) => Focus( builder: (context, key) => Focus(
onKeyEvent: (_, event) { onKeyEvent: sendOnEnter,
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 // Overrides the keyboard shortcut for pasting
child: Actions( child: Actions(
actions: { actions: {
@ -432,12 +489,13 @@ class const Composer(
// The toolbar's paste button doesn't use the shortcut's action, so replace it too // The toolbar's paste button doesn't use the shortcut's action, so replace it too
contextMenuBuilder: (_, editableText) => contextMenuBuilder: (_, editableText) =>
AdaptiveTextSelectionToolbar.buttonItems( AdaptiveTextSelectionToolbar.buttonItems(
anchors: anchors: editableText
editableText.contextMenuAnchors, .contextMenuAnchors,
buttonItems: editableText buttonItems: editableText
.contextMenuButtonItems .contextMenuButtonItems
.map( .map(
(item) => item.type == .paste (item) =>
item.type == .paste
? item.copyWith( ? item.copyWith(
onPressed: () async { onPressed: () async {
editableText editableText
@ -457,7 +515,8 @@ class const Composer(
onContentInserted: (content) { onContentInserted: (content) {
if (attachment != null) return; if (attachment != null) return;
if (content.data case final data?) { if (content.data
case final data?) {
attachBytes( attachBytes(
data, data,
"image.${content.mimeType.split("/").last}", "image.${content.mimeType.split("/").last}",
@ -469,6 +528,8 @@ class const Composer(
), ),
), ),
), ),
},
),
), ),
IconButton( IconButton(
onPressed: onPressed:

View file

@ -323,9 +323,21 @@ final class const RoomChat({
relatedEvent.value, relatedEvent.value,
) )
.onError(showError), .onError(showError),
onSendSticker: (shortcode, image) => onSendSticker:
notifier (
.sendSticker(shortcode, image) shortcode,
image, {
required shouldMention,
}) => notifier
.sendSticker(
shortcode,
image,
shouldMention: shouldMention,
repliedEventRowId:
relationType.value == .reply
? relatedEvent.value
: null,
)
.onError(showError), .onError(showError),
relationType: relationType.value, relationType: relationType.value,
relatedEvent: relatedEvent.value, relatedEvent: relatedEvent.value,