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.endsWith('%40alicyy%3Aexample.com') &&
|
||||||
!action.contains('%40getme')) {
|
!action.contains('%40getme')) {
|
||||||
res = {'displayname': '', 'membership': 'ban'};
|
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' &&
|
} else if (method == 'PUT' &&
|
||||||
action.contains(
|
action.contains(
|
||||||
'/client/v3/rooms/!1234%3AfakeServer.notExisting/send/',
|
'/client/v3/rooms/!1234%3AfakeServer.notExisting/send/',
|
||||||
|
|
@ -201,6 +209,18 @@ class FakeMatrixApi extends BaseClient {
|
||||||
'/client/v3/rooms/!1234%3AfakeServer.notExisting/state/',
|
'/client/v3/rooms/!1234%3AfakeServer.notExisting/state/',
|
||||||
)) {
|
)) {
|
||||||
res = {'event_id': '\$event${_eventCounter++}'};
|
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')) {
|
} else if (action.contains('/client/v3/sync')) {
|
||||||
// Sync requests with timeout
|
// Sync requests with timeout
|
||||||
final timeout = request.url.queryParameters['timeout'];
|
final timeout = request.url.queryParameters['timeout'];
|
||||||
|
|
|
||||||
|
|
@ -118,4 +118,5 @@ abstract class EventTypes {
|
||||||
static const String GroupCallMemberReplaces = '$GroupCallMember.replaces';
|
static const String GroupCallMemberReplaces = '$GroupCallMember.replaces';
|
||||||
static const String GroupCallMemberAssertedIdentity =
|
static const String GroupCallMemberAssertedIdentity =
|
||||||
'$GroupCallMember.asserted_identity';
|
'$GroupCallMember.asserted_identity';
|
||||||
|
static const GroupCallMemberReaction = 'com.famedly.call.member.reaction';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4039,6 +4039,59 @@ class Client extends MatrixApi {
|
||||||
onInitStateChanged: onInitStateChanged,
|
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 {
|
class SdkError {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ abstract class RelationshipTypes {
|
||||||
static const String reply = 'm.in_reply_to';
|
static const String reply = 'm.in_reply_to';
|
||||||
static const String edit = 'm.replace';
|
static const String edit = 'm.replace';
|
||||||
static const String reaction = 'm.annotation';
|
static const String reaction = 'm.annotation';
|
||||||
|
static const String reference = 'm.reference';
|
||||||
static const String thread = 'm.thread';
|
static const String thread = 'm.thread';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,11 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:matrix/src/utils/cached_stream_controller.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/models/voip_id.dart';
|
||||||
import 'package:matrix/src/voip/utils/stream_helper.dart';
|
import 'package:matrix/src/voip/utils/stream_helper.dart';
|
||||||
|
|
||||||
|
|
@ -110,6 +113,9 @@ class GroupCallSession {
|
||||||
return _participants.contains(localParticipant);
|
return _participants.contains(localParticipant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? _reactionsTimer;
|
||||||
|
int _reactionsTicker = 0;
|
||||||
|
|
||||||
/// enter the group call.
|
/// enter the group call.
|
||||||
Future<void> enter({WrappedMediaStream? stream}) async {
|
Future<void> enter({WrappedMediaStream? stream}) async {
|
||||||
if (!(state == GroupCallState.localCallFeedUninitialized ||
|
if (!(state == GroupCallState.localCallFeedUninitialized ||
|
||||||
|
|
@ -136,6 +142,10 @@ class GroupCallSession {
|
||||||
voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId);
|
voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId);
|
||||||
|
|
||||||
await voip.delegate.handleNewGroupCall(this);
|
await voip.delegate.handleNewGroupCall(this);
|
||||||
|
|
||||||
|
_reactionsTimer = Timer.periodic(Duration(seconds: 1), (_) {
|
||||||
|
if (_reactionsTicker > 0) _reactionsTicker--;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> leave() async {
|
Future<void> leave() async {
|
||||||
|
|
@ -147,11 +157,38 @@ class GroupCallSession {
|
||||||
voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId));
|
voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId));
|
||||||
await voip.delegate.handleGroupCallEnded(this);
|
await voip.delegate.handleGroupCallEnded(this);
|
||||||
_resendMemberStateEventTimer?.cancel();
|
_resendMemberStateEventTimer?.cancel();
|
||||||
|
_reactionsTimer?.cancel();
|
||||||
setState(GroupCallState.ended);
|
setState(GroupCallState.ended);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMemberStateEvent() async {
|
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(
|
CallMembership(
|
||||||
userId: client.userID!,
|
userId: client.userID!,
|
||||||
roomId: room.id,
|
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) {
|
if (_resendMemberStateEventTimer != null) {
|
||||||
_resendMemberStateEventTimer!.cancel();
|
_resendMemberStateEventTimer!.cancel();
|
||||||
}
|
}
|
||||||
|
|
@ -271,4 +316,226 @@ class GroupCallSession {
|
||||||
onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
|
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()
|
DateTime.now()
|
||||||
.subtract(voip.timeouts!.expireTsBumpDuration)
|
.subtract(voip.timeouts!.expireTsBumpDuration)
|
||||||
.millisecondsSinceEpoch;
|
.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});
|
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.
|
/// 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,
|
CallMembership callMembership,
|
||||||
) async {
|
) async {
|
||||||
final ownMemberships = getCallMembershipsForUser(
|
final ownMemberships = getCallMembershipsForUser(
|
||||||
|
|
@ -118,7 +119,7 @@ extension FamedlyCallMemberEventsExtension on Room {
|
||||||
'memberships': List.from(ownMemberships.map((e) => e.toJson())),
|
'memberships': List.from(ownMemberships.map((e) => e.toJson())),
|
||||||
};
|
};
|
||||||
|
|
||||||
await setFamedlyCallMemberEvent(
|
return await setFamedlyCallMemberEvent(
|
||||||
newContent,
|
newContent,
|
||||||
callMembership.voip,
|
callMembership.voip,
|
||||||
callMembership.callId,
|
callMembership.callId,
|
||||||
|
|
@ -168,7 +169,7 @@ extension FamedlyCallMemberEventsExtension on Room {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setFamedlyCallMemberEvent(
|
Future<String?> setFamedlyCallMemberEvent(
|
||||||
Map<String, List> newContent,
|
Map<String, List> newContent,
|
||||||
VoIP voip,
|
VoIP voip,
|
||||||
String groupCallId, {
|
String groupCallId, {
|
||||||
|
|
@ -259,7 +260,7 @@ extension FamedlyCallMemberEventsExtension on Room {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.setRoomStateWithKey(
|
return await client.setRoomStateWithKey(
|
||||||
id,
|
id,
|
||||||
EventTypes.GroupCallMember,
|
EventTypes.GroupCallMember,
|
||||||
stateKey,
|
stateKey,
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class CallTimeouts {
|
||||||
|
|
||||||
class CallConstants {
|
class CallConstants {
|
||||||
static final callEventsRegxp = RegExp(
|
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 = {
|
static const callEndedEventTypes = {
|
||||||
|
|
@ -88,4 +88,5 @@ class CallConstants {
|
||||||
static const updateExpireTsTimerDuration = Duration(seconds: 15);
|
static const updateExpireTsTimerDuration = Duration(seconds: 15);
|
||||||
static const expireTsBumpDuration = Duration(seconds: 45);
|
static const expireTsBumpDuration = Duration(seconds: 45);
|
||||||
static const activeSpeakerInterval = Duration(seconds: 5);
|
static const activeSpeakerInterval = Duration(seconds: 5);
|
||||||
|
static const ephemeralReactionTimeout = Duration(seconds: 2);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,8 +192,25 @@ class VoIP {
|
||||||
if (event is Event) {
|
if (event is Event) {
|
||||||
room = event.room;
|
room = event.room;
|
||||||
|
|
||||||
|
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
|
/// this can also be sent in p2p calls when they want to call a specific device
|
||||||
remoteDeviceId = event.content.tryGet<String>('invitee_device_id');
|
remoteDeviceId = event.content.tryGet<String>('invitee_device_id');
|
||||||
|
}
|
||||||
} else if (event is ToDeviceEvent) {
|
} else if (event is ToDeviceEvent) {
|
||||||
final roomId = event.content.tryGet<String>('room_id');
|
final roomId = event.content.tryGet<String>('room_id');
|
||||||
final confId = event.content.tryGet<String>('conf_id');
|
final confId = event.content.tryGet<String>('conf_id');
|
||||||
|
|
@ -211,7 +228,10 @@ class VoIP {
|
||||||
return;
|
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
|
// livekit calls have their own session deduplication logic so ignore sessionId deduplication for them
|
||||||
final destSessionId = event.content.tryGet<String>('dest_session_id');
|
final destSessionId = event.content.tryGet<String>('dest_session_id');
|
||||||
if (destSessionId != currentSessionId) {
|
if (destSessionId != currentSessionId) {
|
||||||
|
|
@ -233,23 +253,33 @@ class VoIP {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final content = event.content;
|
|
||||||
|
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
Logs().w(
|
Logs().w(
|
||||||
'[VOIP] _handleCallEvent call event does not contain a room_id, ignoring',
|
'[VOIP] _handleCallEvent call event does not contain a room_id, ignoring',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (client.userID != null &&
|
}
|
||||||
|
|
||||||
|
final content = event.content;
|
||||||
|
|
||||||
|
if (client.userID != null &&
|
||||||
client.deviceID != null &&
|
client.deviceID != null &&
|
||||||
remoteUserId == client.userID &&
|
remoteUserId == client.userID &&
|
||||||
remoteDeviceId == client.deviceID) {
|
remoteDeviceId == client.deviceID) {
|
||||||
|
// 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(
|
Logs().v(
|
||||||
'Ignoring call event ${event.type} for room ${room.id} from our own device',
|
'Ignoring call event ${event.type} for room ${room.id} from our own device',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (!event.type
|
}
|
||||||
.startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
|
} else if (!{
|
||||||
|
EventTypes.GroupCallMemberEncryptionKeys,
|
||||||
|
EventTypes.GroupCallMemberEncryptionKeysRequest,
|
||||||
|
EventTypes.GroupCallMemberReaction,
|
||||||
|
EventTypes.Redaction,
|
||||||
|
}.contains(event.type)) {
|
||||||
// skip webrtc event checks on encryption_keys
|
// skip webrtc event checks on encryption_keys
|
||||||
final callId = content['call_id'] as String?;
|
final callId = content['call_id'] as String?;
|
||||||
final partyId = content['party_id'] as String?;
|
final partyId = content['party_id'] as String?;
|
||||||
|
|
@ -267,8 +297,7 @@ class VoIP {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (call != null) {
|
} else if (call != null) {
|
||||||
// multiple checks to make sure the events sent are from the the
|
// multiple checks to make sure the events sent are from the expected party
|
||||||
// expected party
|
|
||||||
if (call.room.id != room.id) {
|
if (call.room.id != room.id) {
|
||||||
Logs().w(
|
Logs().w(
|
||||||
'Ignoring call event ${event.type} for room ${room.id} claiming to be for call in room ${call.room.id}',
|
'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,
|
content,
|
||||||
);
|
);
|
||||||
break;
|
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) {
|
CallType getCallType(String sdp) {
|
||||||
try {
|
try {
|
||||||
final session = sdp_transform.parse(sdp);
|
final session = sdp_transform.parse(sdp);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue