diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index a37b70a6..c2651629 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -191,6 +191,14 @@ class FakeMatrixApi extends BaseClient { !action.endsWith('%40alicyy%3Aexample.com') && !action.contains('%40getme')) { res = {'displayname': '', 'membership': 'ban'}; + } else if (method == 'GET' && + action.contains('/client/v1/rooms/') && + action.contains('/relations/')) { + res = { + 'chunk': [], + 'next_batch': null, + 'prev_batch': null, + }; } else if (method == 'PUT' && action.contains( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/', @@ -201,6 +209,18 @@ class FakeMatrixApi extends BaseClient { '/client/v3/rooms/!1234%3AfakeServer.notExisting/state/', )) { res = {'event_id': '\$event${_eventCounter++}'}; + } else if (method == 'PUT' && + action.contains('/client/v3/rooms/') && + action.contains('/state/com.famedly.call.member/')) { + res = {'event_id': '\$event${_eventCounter++}'}; + } else if (method == 'PUT' && + action.contains('/client/v3/rooms/') && + action.contains('/send/com.famedly.call.member.reaction/')) { + res = {'event_id': '\$event${_eventCounter++}'}; + } else if (method == 'PUT' && + action.contains('/client/v3/rooms/') && + action.contains('/redact/')) { + res = {'event_id': '\$event${_eventCounter++}'}; } else if (action.contains('/client/v3/sync')) { // Sync requests with timeout final timeout = request.url.queryParameters['timeout']; diff --git a/lib/matrix_api_lite/model/event_types.dart b/lib/matrix_api_lite/model/event_types.dart index b99282ea..cd495732 100644 --- a/lib/matrix_api_lite/model/event_types.dart +++ b/lib/matrix_api_lite/model/event_types.dart @@ -118,4 +118,5 @@ abstract class EventTypes { static const String GroupCallMemberReplaces = '$GroupCallMember.replaces'; static const String GroupCallMemberAssertedIdentity = '$GroupCallMember.asserted_identity'; + static const GroupCallMemberReaction = 'com.famedly.call.member.reaction'; } diff --git a/lib/src/client.dart b/lib/src/client.dart index b8226386..57b74292 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -4039,6 +4039,59 @@ class Client extends MatrixApi { onInitStateChanged: onInitStateChanged, ); } + + /// Strips all information out of an event which isn't critical to the + /// integrity of the server-side representation of the room. + /// + /// This cannot be undone. + /// + /// Any user with a power level greater than or equal to the `m.room.redaction` + /// event power level may send redaction events in the room. If the user's power + /// level is also greater than or equal to the `redact` power level of the room, + /// the user may redact events sent by other users. + /// + /// Server administrators may redact events sent by users on their server. + /// + /// [roomId] The room from which to redact the event. + /// + /// [eventId] The ID of the event to redact + /// + /// [txnId] The [transaction ID](https://spec.matrix.org/unstable/client-server-api/#transaction-identifiers) for this event. Clients should generate a + /// unique ID; it will be used by the server to ensure idempotency of requests. + /// + /// [reason] The reason for the event being redacted. + /// + /// [metadata] is a map which will be expanded and sent along the reason field + /// + /// returns `event_id`: + /// A unique identifier for the event. + Future redactEventWithMetadata( + String roomId, + String eventId, + String txnId, { + String? reason, + Map? metadata, + }) async { + final requestUri = Uri( + path: + '_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/redact/${Uri.encodeComponent(eventId)}/${Uri.encodeComponent(txnId)}', + ); + final request = http.Request('PUT', baseUri!.resolveUri(requestUri)); + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + request.headers['content-type'] = 'application/json'; + request.bodyBytes = utf8.encode( + jsonEncode({ + if (reason != null) 'reason': reason, + if (metadata != null) ...metadata, + }), + ); + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) unexpectedResponse(response, responseBody); + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return ((v) => v != null ? v as String : null)(json['event_id']); + } } class SdkError { diff --git a/lib/src/event.dart b/lib/src/event.dart index 60c39583..cbe6548a 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -35,6 +35,7 @@ abstract class RelationshipTypes { static const String reply = 'm.in_reply_to'; static const String edit = 'm.replace'; static const String reaction = 'm.annotation'; + static const String reference = 'm.reference'; static const String thread = 'm.thread'; } diff --git a/lib/src/voip/group_call_session.dart b/lib/src/voip/group_call_session.dart index 48e13bfd..9058f76e 100644 --- a/lib/src/voip/group_call_session.dart +++ b/lib/src/voip/group_call_session.dart @@ -19,8 +19,11 @@ import 'dart:async'; import 'dart:core'; +import 'package:collection/collection.dart'; + import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; +import 'package:matrix/src/voip/models/call_reaction_payload.dart'; import 'package:matrix/src/voip/models/voip_id.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart'; @@ -110,6 +113,9 @@ class GroupCallSession { return _participants.contains(localParticipant); } + Timer? _reactionsTimer; + int _reactionsTicker = 0; + /// enter the group call. Future enter({WrappedMediaStream? stream}) async { if (!(state == GroupCallState.localCallFeedUninitialized || @@ -136,6 +142,10 @@ class GroupCallSession { voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId); await voip.delegate.handleNewGroupCall(this); + + _reactionsTimer = Timer.periodic(Duration(seconds: 1), (_) { + if (_reactionsTicker > 0) _reactionsTicker--; + }); } Future leave() async { @@ -147,11 +157,38 @@ class GroupCallSession { voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId)); await voip.delegate.handleGroupCallEnded(this); _resendMemberStateEventTimer?.cancel(); + _reactionsTimer?.cancel(); setState(GroupCallState.ended); } Future sendMemberStateEvent() async { - await room.updateFamedlyCallMemberStateEvent( + // Get current member event ID to preserve permanent reactions + final currentMemberships = room.getCallMembershipsForUser( + client.userID!, + client.deviceID!, + voip, + ); + + final currentMembership = currentMemberships.firstWhereOrNull( + (m) => + m.callId == groupCallId && + m.deviceId == client.deviceID! && + m.application == application && + m.scope == scope && + m.roomId == room.id, + ); + + // Store permanent reactions from the current member event if it exists + List permanentReactions = []; + final membershipExpired = currentMembership?.isExpired ?? false; + + if (currentMembership?.eventId != null && !membershipExpired) { + permanentReactions = await _getPermanentReactionsForEvent( + currentMembership!.eventId!, + ); + } + + final newEventId = await room.updateFamedlyCallMemberStateEvent( CallMembership( userId: client.userID!, roomId: room.id, @@ -169,6 +206,14 @@ class GroupCallSession { ), ); + // Copy permanent reactions to the new member event + if (permanentReactions.isNotEmpty && newEventId != null) { + await _copyPermanentReactionsToNewEvent( + permanentReactions, + newEventId, + ); + } + if (_resendMemberStateEventTimer != null) { _resendMemberStateEventTimer!.cancel(); } @@ -271,4 +316,226 @@ class GroupCallSession { onGroupCallEvent.add(GroupCallStateChange.participantsChanged); } } + + /// Send a reaction event to the group call + /// + /// [emoji] - The reaction emoji (e.g., '🖐️' for hand raise) + /// [name] - The reaction name (e.g., 'hand raise') + /// [isEphemeral] - Whether the reaction is ephemeral (default: true) + /// + /// Returns the event ID of the sent reaction event + Future sendReactionEvent({ + required String emoji, + bool isEphemeral = true, + }) async { + if (isEphemeral && _reactionsTicker > 10) { + throw Exception( + '[sendReactionEvent] manual throttling, too many ephemral reactions sent', + ); + } + + Logs().d('Group call reaction selected: $emoji'); + + final memberships = + room.getCallMembershipsForUser(client.userID!, client.deviceID!, voip); + final membership = memberships.firstWhereOrNull( + (m) => + m.callId == groupCallId && + m.deviceId == client.deviceID! && + m.roomId == room.id && + m.application == application && + m.scope == scope, + ); + + if (membership == null) { + throw Exception( + '[sendReactionEvent] No matching membership found to send group call emoji reaction from ${client.userID!}', + ); + } + + final payload = ReactionPayload( + key: emoji, + isEphemeral: isEphemeral, + callId: groupCallId, + deviceId: client.deviceID!, + relType: RelationshipTypes.reference, + eventId: membership.eventId!, + ); + + // Send reaction as unencrypted event to avoid decryption issues + final txid = client.generateUniqueTransactionId(); + _reactionsTicker++; + return await client.sendMessage( + room.id, + EventTypes.GroupCallMemberReaction, + txid, + payload.toJson(), + ); + } + + /// Remove a reaction event from the group call + /// + /// [eventId] - The event ID of the reaction to remove + /// + /// Returns the event ID of the removed reaction event + Future removeReactionEvent({required String eventId}) async { + return await client.redactEventWithMetadata( + room.id, + eventId, + client.generateUniqueTransactionId(), + metadata: { + 'device_id': client.deviceID, + 'call_id': groupCallId, + 'redacts_type': EventTypes.GroupCallMemberReaction, + }, + ); + } + + /// Get all reactions of a specific type for all participants in the call + /// + /// [emoji] - The reaction emoji to filter by (e.g., '🖐️') + /// + /// Returns a list of [MatrixEvent] objects representing the reactions + Future> getAllReactions({required String emoji}) async { + final reactions = []; + + final memberships = room + .getCallMembershipsFromRoom( + voip, + ) + .values + .expand((e) => e); + + final membershipsForCurrentGroupCall = memberships + .where( + (m) => + m.callId == groupCallId && + m.application == application && + m.scope == scope && + m.roomId == room.id, + ) + .toList(); + + for (final membership in membershipsForCurrentGroupCall) { + if (membership.eventId == null) continue; + + // this could cause a problem in large calls because it would make + // n number of /relations requests where n is the number of participants + // but turns our synapse does not rate limit these so should be fine? + final eventsToProcess = + (await client.getRelatingEventsWithRelTypeAndEventType( + room.id, + membership.eventId!, + RelationshipTypes.reference, + EventTypes.GroupCallMemberReaction, + recurse: false, + limit: 100, + )) + .chunk; + + reactions.addAll( + eventsToProcess.where((event) => event.content['key'] == emoji), + ); + } + + return reactions; + } + + /// Get all permanent reactions for a specific member event ID + /// + /// [eventId] - The member event ID to get reactions for + /// + /// Returns a list of [MatrixEvent] objects representing permanent reactions + Future> _getPermanentReactionsForEvent( + String eventId, + ) async { + final permanentReactions = []; + + try { + final events = await client.getRelatingEventsWithRelTypeAndEventType( + room.id, + eventId, + RelationshipTypes.reference, + EventTypes.GroupCallMemberReaction, + recurse: false, + // makes sure that if you make too many reactions, permanent reactions don't miss out + // hopefully 100 is a good value + limit: 100, + ); + + for (final event in events.chunk) { + final content = event.content; + final isEphemeral = content['is_ephemeral'] as bool? ?? false; + final isRedacted = event.redacts != null; + + if (!isEphemeral && !isRedacted) { + permanentReactions.add(event); + Logs().d( + '[VOIP] Found permanent reaction to preserve: ${content['key']} from ${event.senderId}', + ); + } + } + } catch (e, s) { + Logs().e( + '[VOIP] Failed to get permanent reactions for event $eventId', + e, + s, + ); + } + + return permanentReactions; + } + + /// Copy permanent reactions to the new member event + /// + /// [permanentReactions] - List of permanent reaction events to copy + /// [newEventId] - The event ID of the new membership event + Future _copyPermanentReactionsToNewEvent( + List permanentReactions, + String newEventId, + ) async { + // Re-send each permanent reaction with the new event ID + for (final reactionEvent in permanentReactions) { + try { + final content = reactionEvent.content; + final reactionKey = content['key'] as String?; + + if (reactionKey == null) { + Logs().w( + '[VOIP] Skipping permanent reaction copy: missing reaction key', + ); + continue; + } + + // Build new reaction event with updated event ID + final payload = ReactionPayload( + key: reactionKey, + isEphemeral: false, + callId: groupCallId, + deviceId: client.deviceID!, + relType: RelationshipTypes.reference, + eventId: newEventId, + ); + + // Send the permanent reaction with new event ID + final txid = client.generateUniqueTransactionId(); + await client.sendMessage( + room.id, + EventTypes.GroupCallMemberReaction, + txid, + payload.toJson(), + ); + + Logs().d( + '[VOIP] Copied permanent reaction $reactionKey to new member event $newEventId', + ); + } catch (e, s) { + Logs().e( + '[VOIP] Failed to copy permanent reaction', + e, + s, + ); + } + } + } } diff --git a/lib/src/voip/models/call_membership.dart b/lib/src/voip/models/call_membership.dart index 86945de1..08866296 100644 --- a/lib/src/voip/models/call_membership.dart +++ b/lib/src/voip/models/call_membership.dart @@ -166,4 +166,9 @@ class CallMembership { DateTime.now() .subtract(voip.timeouts!.expireTsBumpDuration) .millisecondsSinceEpoch; + + @override + String toString() { + return 'CallMembership(userId: $userId, callId: $callId, application: $application, scope: $scope, backend: $backend, deviceId: $deviceId, eventId: $eventId, expiresTs: $expiresTs, membershipId: $membershipId, feeds: $feeds, voip: $voip, roomId: $roomId)'; + } } diff --git a/lib/src/voip/models/call_reaction_payload.dart b/lib/src/voip/models/call_reaction_payload.dart new file mode 100644 index 00000000..2c8a6def --- /dev/null +++ b/lib/src/voip/models/call_reaction_payload.dart @@ -0,0 +1,41 @@ +final class ReactionPayload { + final String key; + final bool isEphemeral; + final String callId; + final String deviceId; + final String relType; + final String eventId; + + ReactionPayload({ + required this.key, + required this.isEphemeral, + required this.callId, + required this.deviceId, + required this.relType, + required this.eventId, + }); + + Map toJson() { + return { + 'key': key, + 'is_ephemeral': isEphemeral, + 'call_id': callId, + 'device_id': deviceId, + 'm.relates_to': { + 'rel_type': relType, + 'event_id': eventId, + }, + }; + } + + factory ReactionPayload.fromJson(Map map) { + return ReactionPayload( + key: map['key'] as String, + isEphemeral: map['is_ephemeral'] as bool, + callId: map['call_id'] as String, + deviceId: map['device_id'] as String, + relType: map['m.relates_to']['rel_type'] as String, + eventId: map['m.relates_to']['event_id'] as String, + ); + } +} diff --git a/lib/src/voip/models/matrixrtc_call_event.dart b/lib/src/voip/models/matrixrtc_call_event.dart index 531f6943..82086974 100644 --- a/lib/src/voip/models/matrixrtc_call_event.dart +++ b/lib/src/voip/models/matrixrtc_call_event.dart @@ -18,3 +18,31 @@ final class ParticipantsLeftEvent implements ParticipantsChangeEvent { ParticipantsLeftEvent({required this.participants}); } + +sealed class CallReactionEvent implements MatrixRTCCallEvent {} + +final class CallReactionAddedEvent implements CallReactionEvent { + final CallParticipant participant; + final String reactionKey; + final String membershipEventId; + final String reactionEventId; + final bool isEphemeral; + + CallReactionAddedEvent({ + required this.participant, + required this.reactionKey, + required this.membershipEventId, + required this.reactionEventId, + required this.isEphemeral, + }); +} + +final class CallReactionRemovedEvent implements CallReactionEvent { + final CallParticipant participant; + final String redactedEventId; + + CallReactionRemovedEvent({ + required this.participant, + required this.redactedEventId, + }); +} diff --git a/lib/src/voip/utils/famedly_call_extension.dart b/lib/src/voip/utils/famedly_call_extension.dart index 4e4c9838..d6a6f431 100644 --- a/lib/src/voip/utils/famedly_call_extension.dart +++ b/lib/src/voip/utils/famedly_call_extension.dart @@ -96,7 +96,8 @@ extension FamedlyCallMemberEventsExtension on Room { } /// passing no `CallMembership` removes it from the state event. - Future updateFamedlyCallMemberStateEvent( + /// Returns the event ID of the new membership state event. + Future updateFamedlyCallMemberStateEvent( CallMembership callMembership, ) async { final ownMemberships = getCallMembershipsForUser( @@ -118,7 +119,7 @@ extension FamedlyCallMemberEventsExtension on Room { 'memberships': List.from(ownMemberships.map((e) => e.toJson())), }; - await setFamedlyCallMemberEvent( + return await setFamedlyCallMemberEvent( newContent, callMembership.voip, callMembership.callId, @@ -168,7 +169,7 @@ extension FamedlyCallMemberEventsExtension on Room { } } - Future setFamedlyCallMemberEvent( + Future setFamedlyCallMemberEvent( Map newContent, VoIP voip, String groupCallId, { @@ -259,7 +260,7 @@ extension FamedlyCallMemberEventsExtension on Room { ); } - await client.setRoomStateWithKey( + return await client.setRoomStateWithKey( id, EventTypes.GroupCallMember, stateKey, diff --git a/lib/src/voip/utils/voip_constants.dart b/lib/src/voip/utils/voip_constants.dart index f12ab8dd..0fc881e0 100644 --- a/lib/src/voip/utils/voip_constants.dart +++ b/lib/src/voip/utils/voip_constants.dart @@ -68,7 +68,7 @@ class CallTimeouts { class CallConstants { static final callEventsRegxp = RegExp( - r'm.call.|org.matrix.call.|org.matrix.msc3401.call.|com.famedly.call.', + r'm.call.|org.matrix.call.|org.matrix.msc3401.call.|com.famedly.call.|m.room.redaction', ); static const callEndedEventTypes = { @@ -88,4 +88,5 @@ class CallConstants { static const updateExpireTsTimerDuration = Duration(seconds: 15); static const expireTsBumpDuration = Duration(seconds: 45); static const activeSpeakerInterval = Duration(seconds: 5); + static const ephemeralReactionTimeout = Duration(seconds: 2); } diff --git a/lib/src/voip/voip.dart b/lib/src/voip/voip.dart index d72b2843..8c2d4fd1 100644 --- a/lib/src/voip/voip.dart +++ b/lib/src/voip/voip.dart @@ -192,8 +192,25 @@ class VoIP { if (event is Event) { room = event.room; - /// this can also be sent in p2p calls when they want to call a specific device - remoteDeviceId = event.content.tryGet('invitee_device_id'); + if (event.type == EventTypes.GroupCallMemberReaction) { + final isEphemeral = event.content.tryGet('is_ephemeral')!; + if (isEphemeral && + event.originServerTs.isBefore( + DateTime.now().subtract(CallConstants.ephemeralReactionTimeout), + )) { + Logs().d( + '[VOIP] Ignoring ephemeral group call emoji reaction event of type ${event.type} because it is older than ${CallConstants.ephemeralReactionTimeout}', + ); + return; + } + + // well this is a bit of a mess, but we use normal Events for reactions, + // therefore have to setup the deviceId here + remoteDeviceId = event.content.tryGet('device_id'); + } else { + /// this can also be sent in p2p calls when they want to call a specific device + remoteDeviceId = event.content.tryGet('invitee_device_id'); + } } else if (event is ToDeviceEvent) { final roomId = event.content.tryGet('room_id'); final confId = event.content.tryGet('conf_id'); @@ -211,7 +228,10 @@ class VoIP { return; } - if (!event.type.startsWith(EventTypes.GroupCallMemberEncryptionKeys)) { + if (!{ + EventTypes.GroupCallMemberEncryptionKeys, + EventTypes.GroupCallMemberEncryptionKeysRequest, + }.contains(event.type)) { // livekit calls have their own session deduplication logic so ignore sessionId deduplication for them final destSessionId = event.content.tryGet('dest_session_id'); if (destSessionId != currentSessionId) { @@ -233,23 +253,33 @@ class VoIP { return; } - final content = event.content; - if (room == null) { Logs().w( '[VOIP] _handleCallEvent call event does not contain a room_id, ignoring', ); return; - } else if (client.userID != null && + } + + final content = event.content; + + if (client.userID != null && client.deviceID != null && remoteUserId == client.userID && remoteDeviceId == client.deviceID) { - Logs().v( - 'Ignoring call event ${event.type} for room ${room.id} from our own device', - ); - return; - } else if (!event.type - .startsWith(EventTypes.GroupCallMemberEncryptionKeys)) { + // We don't want to ignore group call reactions from our own device because + // we want to show them on the UI + if (!{EventTypes.GroupCallMemberReaction}.contains(event.type)) { + Logs().v( + 'Ignoring call event ${event.type} for room ${room.id} from our own device', + ); + return; + } + } else if (!{ + EventTypes.GroupCallMemberEncryptionKeys, + EventTypes.GroupCallMemberEncryptionKeysRequest, + EventTypes.GroupCallMemberReaction, + EventTypes.Redaction, + }.contains(event.type)) { // skip webrtc event checks on encryption_keys final callId = content['call_id'] as String?; final partyId = content['party_id'] as String?; @@ -267,8 +297,7 @@ class VoIP { ); return; } else if (call != null) { - // multiple checks to make sure the events sent are from the the - // expected party + // multiple checks to make sure the events sent are from the expected party if (call.room.id != room.id) { Logs().w( 'Ignoring call event ${event.type} for room ${room.id} claiming to be for call in room ${call.room.id}', @@ -359,6 +388,12 @@ class VoIP { content, ); break; + case EventTypes.GroupCallMemberReaction: + await _handleReactionEvent(room, event as MatrixEvent); + break; + case EventTypes.Redaction: + await _handleRedactionEvent(room, event); + break; } } @@ -721,6 +756,161 @@ class VoIP { } } + Future _handleReactionEvent( + Room room, + MatrixEvent event, + ) async { + final content = event.content; + + final callId = content.tryGet('call_id'); + if (callId == null) { + Logs().w( + '[VOIP] _handleReactionEvent: No call ID found in reaction content', + ); + return; + } + + final groupCall = groupCalls[VoipId(roomId: room.id, callId: callId)]; + if (groupCall == null) { + Logs().w( + '[VOIP] _handleReactionEvent: No respective group call found for room ${room.id}, call ID $callId', + ); + return; + } + + final membershipEventId = content + .tryGetMap('m.relates_to') + ?.tryGet('event_id'); + final deviceId = content.tryGet('device_id'); + + if (membershipEventId == null || deviceId == null) { + Logs().w( + '[VOIP] _handleReactionEvent: No event ID or device ID found in reaction content', + ); + return; + } + + final reactionKey = content.tryGet('key'); + final isEphemeral = content.tryGet('is_ephemeral') ?? false; + + if (reactionKey == null) { + Logs().w( + '[VOIP] _handleReactionEvent: No reaction key found in reaction content', + ); + return; + } + + final memberships = + room.getCallMembershipsForUser(event.senderId, deviceId, this); + final membership = memberships.firstWhereOrNull( + (m) => + m.callId == callId && + m.application == 'm.call' && + m.scope == 'm.room', + ); + + if (membership == null || membership.isExpired) { + Logs().w( + '[VOIP] _handleReactionEvent: No matching membership found or found expired for reaction from ${event.senderId}', + ); + return; + } + + if (membership.eventId != membershipEventId) { + Logs().w( + '[VOIP] _handleReactionEvent: Event ID mismatch, ignoring reaction on old event from ${event.senderId}', + ); + return; + } + + final participant = CallParticipant( + this, + userId: event.senderId, + deviceId: deviceId, + ); + + final reaction = CallReactionAddedEvent( + participant: participant, + reactionKey: reactionKey, + membershipEventId: membershipEventId, + reactionEventId: event.eventId, + isEphemeral: isEphemeral, + ); + + groupCall.matrixRTCEventStream.add(reaction); + + Logs().d( + '[VOIP] _handleReactionEvent: Sent reaction event: $reaction', + ); + } + + Future _handleRedactionEvent( + Room room, + BasicEventWithSender event, + ) async { + final content = event.content; + if (content.tryGet('redacts_type') != + EventTypes.GroupCallMemberReaction) { + // ignore it + Logs().v( + '[_handleRedactionEvent] Ignoring redaction event ${event.toJson()} because not a call reaction redaction', + ); + return; + } + final redactedEventId = content.tryGet('redacts'); + + if (redactedEventId == null) { + Logs().v( + '[VOIP] _handleRedactionEvent: Missing sender or redacted event ID', + ); + return; + } + + final deviceId = event.content.tryGet('device_id'); + if (deviceId == null) { + Logs().w( + '[VOIP] _handleRedactionEvent: Could not find device_id in redacted event $redactedEventId', + ); + return; + } + + Logs().d( + '[VOIP] _handleRedactionEvent: Device ID from redacted event: $deviceId', + ); + + // Route to all active group calls in the room + final groupCall = groupCalls.values.firstWhereOrNull( + (m) => + m.room.id == room.id && + m.state == GroupCallState.entered && + m.groupCallId == event.content.tryGet('call_id') && + m.application == 'm.call' && + m.scope == 'm.room', + ); + + if (groupCall == null) { + Logs().w( + '[_handleRedactionEvent] could not find group call for event ${event.toJson()}', + ); + return; + } + + final participant = CallParticipant( + this, + userId: event.senderId, + deviceId: deviceId, + ); + + // We don't know the specific reaction key from redaction events + // The listeners can filter based on their current state + final reactionEvent = CallReactionRemovedEvent( + participant: participant, + redactedEventId: redactedEventId, + ); + + groupCall.matrixRTCEventStream.add(reactionEvent); + } + CallType getCallType(String sdp) { try { final session = sdp_transform.parse(sdp); @@ -872,10 +1062,10 @@ class VoIP { if (!room.canJoinGroupCall) { throw MatrixSDKVoipException( ''' - User ${client.userID}:${client.deviceID} is not allowed to join famedly calls in room ${room.id}, - canJoinGroupCall: ${room.canJoinGroupCall}, - groupCallsEnabledForEveryone: ${room.groupCallsEnabledForEveryone}, - needed: ${room.powerForChangingStateEvent(EventTypes.GroupCallMember)}, + User ${client.userID}:${client.deviceID} is not allowed to join famedly calls in room ${room.id}, + canJoinGroupCall: ${room.canJoinGroupCall}, + groupCallsEnabledForEveryone: ${room.groupCallsEnabledForEveryone}, + needed: ${room.powerForChangingStateEvent(EventTypes.GroupCallMember)}, own: ${room.ownPowerLevel}} plMap: ${room.getState(EventTypes.RoomPowerLevels)?.content} ''', diff --git a/test/voip_reactions_test.dart b/test/voip_reactions_test.dart new file mode 100644 index 00000000..4114b19e --- /dev/null +++ b/test/voip_reactions_test.dart @@ -0,0 +1,1342 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'fake_client.dart'; +import 'webrtc_stub.dart'; + +void main() { + late Client matrix; + late Room room; + late VoIP voip; + + final testEmojis = [ + {'emoji': '🖐️', 'name': 'hand raise'}, + {'emoji': '👍', 'name': 'thumbs up'}, + {'emoji': '👏', 'name': 'clap'}, + {'emoji': '❤️', 'name': 'heart'}, + {'emoji': '😂', 'name': 'laugh'}, + ]; + + group('VoIP Reaction Events Tests', () { + Logs().level = Level.info; + + setUp(() async { + matrix = await getClient(); + await matrix.abortSync(); + + voip = VoIP(matrix, MockWebRTCDelegate()); + VoIP.customTxid = '1234'; + final id = '!calls:example.com'; + room = matrix.getRoomById(id)!; + }); + + test('Test hand raise reaction receiving and state management', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_reactions', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_reactions', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_reactions', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session since room.setState doesn't trigger the VoIP listener + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = voip.getGroupCallById(room.id, 'test_call_reactions'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Simulate receiving a hand raise reaction event + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_reactions', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_reactions', + }, + }, + senderId: '@alice:testing.com', + eventId: 'hand_raise_reaction', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for the reaction to be processed + await Future.delayed(Duration(milliseconds: 100)); + + // Verify the reaction was received + expect(reactionEvents.length, 1); + expect(reactionEvents.first, isA()); + + final addedEvent = reactionEvents.first as CallReactionAddedEvent; + expect(addedEvent.reactionKey, '🖐️'); + expect(addedEvent.participant.userId, '@alice:testing.com'); + expect(addedEvent.membershipEventId, 'membership_event_reactions'); + + await subscription.cancel(); + }); + + test('Test hand raise reaction removal via redaction', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_redaction', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_redaction', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_redaction', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = voip.getGroupCallById(room.id, 'test_call_redaction'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // First, add a hand raise reaction + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something1', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_redaction', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_redaction', + }, + }, + senderId: '@alice:testing.com', + eventId: 'hand_raise_to_redact', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 50)); + + // Now simulate a redaction event + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something2', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.Redaction, + content: { + 'redacts': 'hand_raise_to_redact', + 'device_id': 'device123', + 'redacts_type': 'com.famedly.call.member.reaction', + 'call_id': 'test_call_redaction', + 'reason': 'Hand lowered', + }, + senderId: '@alice:testing.com', + eventId: 'redaction_event', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify both events were received + expect(reactionEvents.length, 2); + expect(reactionEvents[0], isA()); + expect(reactionEvents[1], isA()); + + final addedEvent = reactionEvents[0] as CallReactionAddedEvent; + final removedEvent = reactionEvents[1] as CallReactionRemovedEvent; + + expect(addedEvent.reactionKey, '🖐️'); + expect(removedEvent.redactedEventId, 'hand_raise_to_redact'); + expect(removedEvent.participant.userId, '@alice:testing.com'); + + await subscription.cancel(); + }); + + test('Test multiple participants hand raise reactions', () async { + // Set up multiple group call memberships + final membership1 = CallMembership( + userId: '@user1:testing.com', + callId: 'test_call_multi_reactions', + backend: MeshBackend(), + deviceId: 'device1', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_1', + ); + + final membership2 = CallMembership( + userId: '@user2:testing.com', + callId: 'test_call_multi_reactions', + backend: MeshBackend(), + deviceId: 'device2', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_2', + ); + + // Set up the room state with both memberships + room.setState( + Event( + content: { + 'memberships': [membership1.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_1', + senderId: '@user1:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@user1:testing.com', + ), + ); + + room.setState( + Event( + content: { + 'memberships': [membership2.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_2', + senderId: '@user2:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@user2:testing.com', + ), + ); + + // Manually create the group call sessions + await voip.createGroupCallFromRoomStateEvent(membership1); + await voip.createGroupCallFromRoomStateEvent(membership2); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_multi_reactions'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Simulate multiple hand raise reactions + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_multi_reactions', + 'device_id': 'device1', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_1', + }, + }, + senderId: '@user1:testing.com', + eventId: 'hand_raise_user1', + originServerTs: DateTime.now(), + ), + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_multi_reactions', + 'device_id': 'device2', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_2', + }, + }, + senderId: '@user2:testing.com', + eventId: 'hand_raise_user2', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify both reactions were received + expect(reactionEvents.length, 2); + expect( + reactionEvents.every((event) => event is CallReactionAddedEvent), + true, + ); + + final event1 = reactionEvents[0] as CallReactionAddedEvent; + final event2 = reactionEvents[1] as CallReactionAddedEvent; + + expect(event1.reactionKey, '🖐️'); + expect(event2.reactionKey, '🖐️'); + expect(event1.participant.userId, '@user1:testing.com'); + expect(event2.participant.userId, '@user2:testing.com'); + + await subscription.cancel(); + }); + + test('Test current user own reaction events are processed', () async { + // Set up a group call membership for the current user + final membership = CallMembership( + userId: matrix.userID!, + callId: 'test_call_own_reactions', + backend: MeshBackend(), + deviceId: matrix.deviceID!, + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'my_membership_event', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'my_membership_event', + senderId: matrix.userID!, + originServerTs: DateTime.now(), + room: room, + stateKey: matrix.userID!, + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_own_reactions'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Simulate the current user raising their hand + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_own_reactions', + 'device_id': matrix.deviceID!, + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'my_membership_event', + }, + }, + senderId: matrix.userID!, + eventId: 'my_hand_raise', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify the user's own reaction was processed + expect(reactionEvents.length, 1); + expect(reactionEvents.first, isA()); + + final addedEvent = reactionEvents.first as CallReactionAddedEvent; + expect(addedEvent.reactionKey, '🖐️'); + expect(addedEvent.participant.userId, matrix.userID!); + expect(addedEvent.membershipEventId, 'my_membership_event'); + + await subscription.cancel(); + }); + + test('Test sending hand raise reaction through GroupCallSession', () async { + // Set up a group call membership for the current user + final membership = CallMembership( + userId: matrix.userID!, + callId: 'test_call_send_reaction', + backend: MeshBackend(), + deviceId: matrix.deviceID!, + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'send_membership_event', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'send_membership_event', + senderId: matrix.userID!, + originServerTs: DateTime.now(), + room: room, + stateKey: matrix.userID!, + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_send_reaction'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Test sending a hand raise reaction + // This will fail with the fake client but should not crash + final eventId = await groupCall.sendReactionEvent(emoji: '🖐️'); + // With fake client, this will return a valid event ID now that we added the endpoint + expect(eventId, isNotNull); // Expected with updated fake client + + // Test removing a reaction (will also fail with fake client) + await groupCall.removeReactionEvent(eventId: 'fake_reaction_id'); + + // The test passes if we reach here without crashing + expect(true, true); + }); + + test('Test getAllReactions includes current user reactions', () async { + // Set up group call memberships for multiple users including current user + final currentUserMembership = CallMembership( + userId: matrix.userID!, + callId: 'test_call_get_all', + backend: MeshBackend(), + deviceId: matrix.deviceID!, + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'current_user_membership', + ); + + final otherUserMembership = CallMembership( + userId: '@other:testing.com', + callId: 'test_call_get_all', + backend: MeshBackend(), + deviceId: 'other_device', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'other_user_membership', + ); + + // Set up the room state with both memberships + room.setState( + Event( + content: { + 'memberships': [currentUserMembership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'current_user_membership', + senderId: matrix.userID!, + originServerTs: DateTime.now(), + room: room, + stateKey: matrix.userID!, + ), + ); + + room.setState( + Event( + content: { + 'memberships': [otherUserMembership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'other_user_membership', + senderId: '@other:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@other:testing.com', + ), + ); + + // Manually create the group call sessions + await voip.createGroupCallFromRoomStateEvent(currentUserMembership); + await voip.createGroupCallFromRoomStateEvent(otherUserMembership); + + // Get the group call session + final groupCall = voip.getGroupCallById(room.id, 'test_call_get_all'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Test getAllReactions method (will return empty with fake client but should not crash) + final reactions = await groupCall.getAllReactions(emoji: '🖐️'); + + // With fake client, this will return empty list, but the method should exist and be callable + expect(reactions, isA>()); + + // The test passes if we reach here without crashing + expect(true, true); + }); + + test('Test invalid reaction events are ignored', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_invalid_reactions', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_invalid_reactions', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_invalid_reactions', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_invalid_reactions'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Test invalid reaction events + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + // Missing m.relates_to + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_invalid_reactions', + 'device_id': 'device123', + // Missing m.relates_to + }, + senderId: '@alice:testing.com', + eventId: 'invalid_reaction_1', + originServerTs: DateTime.now(), + ), + // Missing key in content + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + // Missing key + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_invalid_reactions', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_invalid_reactions', + }, + }, + senderId: '@alice:testing.com', + eventId: 'invalid_reaction_2', + originServerTs: DateTime.now(), + ), + // Missing device_id + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': '🖐️', + 'name': 'hand raise', + 'is_ephemeral': true, + 'call_id': 'test_call_invalid_reactions', + // Missing device_id + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_invalid_reactions', + }, + }, + senderId: '@alice:testing.com', + eventId: 'invalid_reaction_3', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify no invalid reactions were processed + expect(reactionEvents.isEmpty, true); + + await subscription.cancel(); + }); + + test('Test invalid redaction events are ignored', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_invalid_redactions', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_invalid_redactions', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_invalid_redactions', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_invalid_redactions'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Test invalid redaction events + await matrix.handleSync( + SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + // Missing redacts field + MatrixEvent( + type: EventTypes.Redaction, + content: { + // Missing redacts + 'reason': 'Hand lowered', + }, + senderId: '@alice:testing.com', + eventId: 'invalid_redaction_1', + originServerTs: DateTime.now(), + ), + // Empty content + MatrixEvent( + type: EventTypes.Redaction, + content: {}, + senderId: '@alice:testing.com', + eventId: 'invalid_redaction_2', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify no invalid redactions were processed + expect(reactionEvents.isEmpty, true); + + await subscription.cancel(); + }); + + test('Test different emoji reactions (thumbs up, clap, heart)', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_emoji_variety', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_emoji_variety', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_emoji_variety', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_emoji_variety'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Test different emoji reactions using first 5 emojis + final emojis = testEmojis.take(5).toList(); + + for (int i = 0; i < emojis.length; i++) { + await matrix.handleSync( + SyncUpdate( + nextBatch: 'emoji_batch_$i', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': emojis[i]['emoji']!, + 'name': emojis[i]['name']!, + 'is_ephemeral': true, + 'call_id': 'test_call_emoji_variety', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_emoji_variety', + }, + }, + senderId: '@alice:testing.com', + eventId: 'emoji_reaction_$i', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Small delay between reactions + await Future.delayed(Duration(milliseconds: 10)); + } + + // Wait for all reactions to be processed + await Future.delayed(Duration(milliseconds: 100)); + + // Verify all emoji reactions were received + expect(reactionEvents.length, emojis.length); + expect( + reactionEvents.every((event) => event is CallReactionAddedEvent), + true, + ); + + // Verify each emoji was processed correctly + for (int i = 0; i < emojis.length; i++) { + final event = reactionEvents[i] as CallReactionAddedEvent; + expect(event.reactionKey, emojis[i]['emoji']); + expect(event.participant.userId, '@alice:testing.com'); + expect(event.isEphemeral, true); + } + + await subscription.cancel(); + }); + + test('Test ephemeral vs permanent reactions', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_ephemeral_permanent', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_ephemeral_permanent', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_ephemeral_permanent', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_ephemeral_permanent'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Send both ephemeral and permanent reactions + await matrix.handleSync( + SyncUpdate( + nextBatch: 'ephemeral_permanent_batch', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + // Ephemeral reaction + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': testEmojis[0]['emoji']!, + 'name': testEmojis[0]['name']!, + 'is_ephemeral': true, + 'call_id': 'test_call_ephemeral_permanent', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_ephemeral_permanent', + }, + }, + senderId: '@alice:testing.com', + eventId: 'ephemeral_reaction', + originServerTs: DateTime.now(), + ), + // Permanent reaction + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': testEmojis[1]['emoji']!, + 'name': testEmojis[1]['name']!, + 'is_ephemeral': false, + 'call_id': 'test_call_ephemeral_permanent', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_ephemeral_permanent', + }, + }, + senderId: '@alice:testing.com', + eventId: 'permanent_reaction', + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify both reactions were received + expect(reactionEvents.length, 2); + expect( + reactionEvents.every((event) => event is CallReactionAddedEvent), + true, + ); + + final ephemeralEvent = reactionEvents[0] as CallReactionAddedEvent; + final permanentEvent = reactionEvents[1] as CallReactionAddedEvent; + + // Verify ephemeral reaction properties + expect(ephemeralEvent.reactionKey, testEmojis[0]['emoji']); + expect(ephemeralEvent.isEphemeral, true); + expect(ephemeralEvent.participant.userId, '@alice:testing.com'); + + // Verify permanent reaction properties + expect(permanentEvent.reactionKey, testEmojis[1]['emoji']); + expect(permanentEvent.isEphemeral, false); + expect(permanentEvent.participant.userId, '@alice:testing.com'); + + await subscription.cancel(); + }); + + test('Test ephemeral reaction timeout handling', () async { + // Set up a group call membership + final membership = CallMembership( + userId: '@alice:testing.com', + callId: 'test_call_ephemeral_timeout', + backend: MeshBackend(), + deviceId: 'device123', + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'membership_event_ephemeral_timeout', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'membership_event_ephemeral_timeout', + senderId: '@alice:testing.com', + originServerTs: DateTime.now(), + room: room, + stateKey: '@alice:testing.com', + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = + voip.getGroupCallById(room.id, 'test_call_ephemeral_timeout'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Listen for reaction events + final reactionEvents = []; + final subscription = + groupCall.matrixRTCEventStream.stream.listen((event) { + if (event is CallReactionEvent) { + reactionEvents.add(event); + } + }); + + // Create an old ephemeral reaction (older than timeout) + final oldTimestamp = DateTime.now().subtract(Duration(minutes: 10)); + + await matrix.handleSync( + SyncUpdate( + nextBatch: 'timeout_batch', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + // Old ephemeral reaction (should be ignored) + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': testEmojis[0]['emoji']!, + 'name': 'old ${testEmojis[0]['name']}', + 'is_ephemeral': true, + 'call_id': 'test_call_ephemeral_timeout', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_ephemeral_timeout', + }, + }, + senderId: '@alice:testing.com', + eventId: 'old_ephemeral_reaction', + originServerTs: oldTimestamp, + ), + // Recent ephemeral reaction (should be processed) + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': testEmojis[1]['emoji']!, + 'name': 'recent ${testEmojis[1]['name']}', + 'is_ephemeral': true, + 'call_id': 'test_call_ephemeral_timeout', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_ephemeral_timeout', + }, + }, + senderId: '@alice:testing.com', + eventId: 'recent_ephemeral_reaction', + originServerTs: DateTime.now(), + ), + // Old permanent reaction (should still be processed) + MatrixEvent( + type: EventTypes.GroupCallMemberReaction, + content: { + 'key': testEmojis[3]['emoji']!, + 'name': 'old ${testEmojis[3]['name']}', + 'is_ephemeral': false, + 'call_id': 'test_call_ephemeral_timeout', + 'device_id': 'device123', + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': 'membership_event_ephemeral_timeout', + }, + }, + senderId: '@alice:testing.com', + eventId: 'old_permanent_reaction', + originServerTs: oldTimestamp, + ), + ], + ), + ), + }, + ), + ), + ); + + // Wait for processing + await Future.delayed(Duration(milliseconds: 100)); + + // Verify only recent ephemeral and old permanent reactions were processed + // Old ephemeral should be ignored due to timeout + expect(reactionEvents.length, 2); + + final processedReactions = reactionEvents.cast(); + + // Should have recent ephemeral thumbs up + expect( + processedReactions.any( + (event) => + event.reactionKey == testEmojis[1]['emoji'] && + event.isEphemeral == true, + ), + true, + ); + + // Should have old permanent heart + expect( + processedReactions.any( + (event) => + event.reactionKey == testEmojis[3]['emoji'] && + event.isEphemeral == false, + ), + true, + ); + + // Should NOT have old ephemeral hand raise + expect( + processedReactions + .any((event) => event.reactionKey == testEmojis[0]['emoji']), + false, + ); + + await subscription.cancel(); + }); + + test('Test sending different emoji types through GroupCallSession', + () async { + // Set up a group call membership for the current user + final membership = CallMembership( + userId: matrix.userID!, + callId: 'test_call_send_emojis', + backend: MeshBackend(), + deviceId: matrix.deviceID!, + expiresTs: + DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch, + roomId: room.id, + membershipId: voip.currentSessionId, + voip: voip, + eventId: 'send_emojis_membership_event', + ); + + // Set up the room state with the membership + room.setState( + Event( + content: { + 'memberships': [membership.toJson()], + }, + type: EventTypes.GroupCallMember, + eventId: 'send_emojis_membership_event', + senderId: matrix.userID!, + originServerTs: DateTime.now(), + room: room, + stateKey: matrix.userID!, + ), + ); + + // Manually create the group call session + await voip.createGroupCallFromRoomStateEvent(membership); + + // Get the group call session + final groupCall = voip.getGroupCallById(room.id, 'test_call_send_emojis'); + expect(groupCall, isNotNull); + + // Enter the group call so it can process reactions + await groupCall!.enter(); + + // Test sending different emoji reactions using all test emojis + for (final emojiData in testEmojis) { + // Test ephemeral reaction + final ephemeralEventId = await groupCall.sendReactionEvent( + emoji: emojiData['emoji']!, + isEphemeral: true, + ); + expect(ephemeralEventId, isNotNull); + + // Test permanent reaction + final permanentEventId = await groupCall.sendReactionEvent( + emoji: emojiData['emoji']!, + isEphemeral: false, + ); + expect(permanentEventId, isNotNull); + + // Small delay between sends + await Future.delayed(Duration(milliseconds: 10)); + } + + // Test removing reactions (will work with fake client) + await room.redactEvent('fake_reaction_id_1'); + await room.redactEvent('fake_reaction_id_2'); + + // The test passes if we reach here without crashing + expect(true, true); + }); + }); +}