Allow sending reactions

(but not redacting them yet)
This commit is contained in:
Henry Hiles 2026-04-12 14:11:18 -04:00
commit 1dcf3018a2
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 129 additions and 60 deletions

View file

@ -2,6 +2,7 @@
"cSpell.words": [ "cSpell.words": [
"Appbar", "Appbar",
"Displayname", "Displayname",
"fluttertagger",
"Homeserver", "Homeserver",
"localpart", "localpart",
"prefs", "prefs",

View file

@ -28,6 +28,7 @@ import "package:nexus/models/profile.dart";
import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/requests/report_request.dart"; import "package:nexus/models/requests/report_request.dart";
import "package:nexus/models/requests/send_event_request.dart";
import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/models/requests/set_membership_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
@ -80,9 +81,13 @@ class ClientController extends AsyncNotifier<int> {
case "send_complete": case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]); final event = Event.fromJson(decodedMuksEvent["event"]);
ref if (event.type == "m.room.message") {
.watch(NewEventsController.provider(event.roomId).notifier) ref
.add(IList([event])); .watch(
NewEventsController.provider(event.roomId).notifier,
)
.add(IList([event]));
}
break; break;
case "sync_complete": case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent); final syncData = SyncData.fromJson(decodedMuksEvent);
@ -164,6 +169,9 @@ 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 =>
Event.fromJson(await _sendCommand("send_event", request.toJson()));
Future<String?> verify(String recoveryKey) async { Future<String?> verify(String recoveryKey) async {
try { try {
await _sendCommand("verify", {"recovery_key": recoveryKey}); await _sendCommand("verify", {"recovery_key": recoveryKey});

View file

@ -16,6 +16,7 @@ import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_event_request.dart";
import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
@ -328,6 +329,25 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
return await controller.scrollToMessage(message.id); return await controller.scrollToMessage(message.id);
} }
Future<void> sendReaction(String reaction, Message message) async {
final client = ref.watch(ClientController.provider.notifier);
await client.sendEvent(
SendEventRequest(
roomId: roomId,
type: "m.reaction",
content: {
"m.relates_to": {
"event_id": message.id,
"rel_type": "m.annotation",
"key": reaction,
},
},
disableEncryption: true,
),
);
}
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, InMemoryChatController, String>( .autoDispose<RoomChatController, InMemoryChatController, String>(
RoomChatController.new, RoomChatController.new,

View file

@ -0,0 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "send_event_request.freezed.dart";
part "send_event_request.g.dart";
@freezed
abstract class SendEventRequest with _$SendEventRequest {
const factory SendEventRequest({
required String roomId,
required String type,
required Map<String, dynamic> content,
@Default(false) bool disableEncryption,
}) = _SendEventRequest;
factory SendEventRequest.fromJson(Map<String, Object?> json) =>
_$SendEventRequestFromJson(json);
}

View file

@ -1,27 +1,20 @@
import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart";
import "package:timeago/timeago.dart"; import "package:timeago/timeago.dart";
class MessageWrapper extends ConsumerWidget { class MessageWrapper extends StatelessWidget {
final Message message; final Message message;
final Widget child; final Widget child;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final error = message.metadata?["error"]; final error = message.metadata?["error"];
final clientState = ref.watch(ClientStateController.provider);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
@ -78,53 +71,7 @@ class MessageWrapper extends ConsumerWidget {
color: theme.colorScheme.error, color: theme.colorScheme.error,
), ),
), ),
Wrap( ReactionRow(message),
spacing: 4,
runSpacing: 4,
children:
clientState?.homeserverUrl == null ||
message.reactions == null
? []
: message.reactions!.mapTo((reaction, reactors) {
final selected = reactors.contains(
clientState!.userId,
);
return SizedBox(
child: Tooltip(
message: reactors.join(", "),
child: ChoiceChip(
showCheckmark: false,
selected: selected,
label: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
reaction.startsWith("mxc://")
? Image(
height: 20,
image: CachedNetworkImage(
headers: ref.headers,
Uri.parse(reaction)
.mxcToHttps(
clientState
.homeserverUrl!,
)
.toString(),
ref.watch(
CrossCacheController.provider,
),
),
)
: Text(reaction),
Text(reactors.length.toString()),
],
),
onSelected: (value) {}, // TODO
),
),
);
}).toList(),
),
], ],
), ),
), ),

View file

@ -0,0 +1,77 @@
import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
class ReactionRow extends ConsumerWidget {
final Message message;
const ReactionRow(this.message, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientState = ref.watch(ClientStateController.provider);
return Wrap(
spacing: 4,
runSpacing: 4,
children: clientState?.homeserverUrl == null || message.reactions == null
? []
: message.reactions!.mapTo((reaction, reactors) {
final selected = reactors.contains(clientState!.userId);
return SizedBox(
child: Tooltip(
message: reactors.join(", "),
child: ChoiceChip(
showCheckmark: false,
selected: selected,
label: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
reaction.startsWith("mxc://")
? Image(
height: 20,
image: CachedNetworkImage(
headers: ref.headers,
Uri.parse(reaction)
.mxcToHttps(clientState.homeserverUrl!)
.toString(),
ref.watch(CrossCacheController.provider),
),
)
: Text(reaction),
Text(reactors.length.toString()),
],
),
onSelected: (value) async {
final roomId = ref.watch(
SelectedRoomController.provider.select(
(value) => value?.metadata?.id,
),
);
if (roomId == null) return;
final controller = ref.watch(
RoomChatController.provider(roomId).notifier,
);
if (selected) {
// TODO: remove
} else {
await controller.sendReaction(reaction, message);
}
},
),
),
);
}).toList(),
);
}
}