parent
51e3fe1007
commit
7baf56adef
5 changed files with 252 additions and 153 deletions
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue