feat: reactions for voip calls
Co-authored-by: Karthikeyan S <karthikeyan.s@famedly.com> Co-authored-by: Yash Garg <me@yashgarg.dev>
This commit is contained in:
parent
102d04304e
commit
6c17e3cfdf
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String?> redactEventWithMetadata(
|
||||
String roomId,
|
||||
String eventId,
|
||||
String txnId, {
|
||||
String? reason,
|
||||
Map<String, Object?>? 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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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<void> 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<MatrixEvent> 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<String> 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<String?> 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<List<MatrixEvent>> getAllReactions({required String emoji}) async {
|
||||
final reactions = <MatrixEvent>[];
|
||||
|
||||
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<List<MatrixEvent>> _getPermanentReactionsForEvent(
|
||||
String eventId,
|
||||
) async {
|
||||
final permanentReactions = <MatrixEvent>[];
|
||||
|
||||
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<void> _copyPermanentReactionsToNewEvent(
|
||||
List<MatrixEvent> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,8 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
}
|
||||
|
||||
/// passing no `CallMembership` removes it from the state event.
|
||||
Future<void> updateFamedlyCallMemberStateEvent(
|
||||
/// Returns the event ID of the new membership state event.
|
||||
Future<String?> 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<void> setFamedlyCallMemberEvent(
|
||||
Future<String?> setFamedlyCallMemberEvent(
|
||||
Map<String, List> newContent,
|
||||
VoIP voip,
|
||||
String groupCallId, {
|
||||
|
|
@ -259,7 +260,7 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
);
|
||||
}
|
||||
|
||||
await client.setRoomStateWithKey(
|
||||
return await client.setRoomStateWithKey(
|
||||
id,
|
||||
EventTypes.GroupCallMember,
|
||||
stateKey,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>('invitee_device_id');
|
||||
if (event.type == EventTypes.GroupCallMemberReaction) {
|
||||
final isEphemeral = event.content.tryGet<bool>('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<String>('device_id');
|
||||
} else {
|
||||
/// this can also be sent in p2p calls when they want to call a specific device
|
||||
remoteDeviceId = event.content.tryGet<String>('invitee_device_id');
|
||||
}
|
||||
} else if (event is ToDeviceEvent) {
|
||||
final roomId = event.content.tryGet<String>('room_id');
|
||||
final confId = event.content.tryGet<String>('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<String>('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<void> _handleReactionEvent(
|
||||
Room room,
|
||||
MatrixEvent event,
|
||||
) async {
|
||||
final content = event.content;
|
||||
|
||||
final callId = content.tryGet<String>('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<String, String>('m.relates_to')
|
||||
?.tryGet<String>('event_id');
|
||||
final deviceId = content.tryGet<String>('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<String>('key');
|
||||
final isEphemeral = content.tryGet<bool>('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<void> _handleRedactionEvent(
|
||||
Room room,
|
||||
BasicEventWithSender event,
|
||||
) async {
|
||||
final content = event.content;
|
||||
if (content.tryGet<String>('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<String>('redacts');
|
||||
|
||||
if (redactedEventId == null) {
|
||||
Logs().v(
|
||||
'[VOIP] _handleRedactionEvent: Missing sender or redacted event ID',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceId = event.content.tryGet<String>('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}
|
||||
''',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue