This commit is contained in:
OfficialDakari 2025-10-16 20:18:42 +05:00
commit c1d8f79976
15 changed files with 2031 additions and 31 deletions

View File

@ -1,3 +1,35 @@
## [3.0.1] 15th October 2025
- feat: Make display sending event configurable in Room.sendEvent() (Christian Kußowski)
- chore: tidy up call membership event (td)
- fix: Remove avatar crashes with invalid uri (Christian Kußowski)
## [3.0.0] 7th October 2025
#### Migration hints:
- With extended profiles in Matrix 1.16 you now have to use `Client.getProfileField()` instead of `Client.getAvatar()` or `Client.getDisplayname()`.
- You no longer need to ship [flutter_openssl_crypto](https://pub.dev/packages/flutter_openssl_crypto). The necessary encryption algorithms now come from the Vodozemac package. This should make the platform integration much easier.
#### All changes:
- feat: Add deleteDeviceDisplayName() method to matrix API (Christian Kußowski)
- feat: Add onProgress for upload and download methods (Christian Kußowski)
- feat: Auto refresh last event after limited timeline (Christian Kußowski)
- feat: Implement get mentions from event content (Christian Kußowski)
- feat: Leave DM rooms and invite when ignoring a user (Christian Kußowski)
- feat: reactions for voip calls (td)
- feat: Set m.mention field when sending text event (Christian Kußowski)
- fix: (BREAKING CHANGE) remove only your device call membership if room is not msc3757 (td)
- fix: Set unread notification count only if not null in sync (Christian Kußowski)
- refactor: migrate to web and js_interop pkgs (Karthikeyan S)
- refactor: Return a better default for lastEventReceivedTime (Christian Kußowski)
- refactor: Support matrix spec 1.16 (Christian Kußowski)
- refactor: Upgrade to vodozemac cryptoutils (Christian Kußowski)
- chore: Remove unused callbacks (Christian Kußowski)
- chore: Remove unused dependency (Christian Kußowski)
- chore: Revert on upload progress (Christian Kußowski)
## [2.0.1] 9th Sept 2025
- fix: scheduled event list init (td)

View File

@ -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'];

View File

@ -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';
}

View File

@ -1614,7 +1614,7 @@ class Client extends MatrixApi {
await setProfileField(
userID!,
'avatar_url',
{'avatar_url': Uri.parse('')},
{'avatar_url': ''},
);
return;
}
@ -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 {

View File

@ -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';
}

View File

@ -711,6 +711,12 @@ class Room {
String? threadLastEventId,
StringBuffer? commandStdout,
bool addMentions = true,
/// Displays an event in the timeline with the transaction ID as the event
/// ID and a status of SENDING, SENT or ERROR until it gets replaced by
/// the sync event. Using this can display a different sort order of events
/// as the sync event does replace but not relocate the pending event.
bool displayPendingEvent = true,
}) {
if (parseCommands) {
return client.parseAndRunCommand(
@ -784,6 +790,7 @@ class Room {
editEventId: editEventId,
threadRootEventId: threadRootEventId,
threadLastEventId: threadLastEventId,
displayPendingEvent: displayPendingEvent,
);
}
@ -838,6 +845,12 @@ class Room {
Map<String, dynamic>? extraContent,
String? threadRootEventId,
String? threadLastEventId,
/// Displays an event in the timeline with the transaction ID as the event
/// ID and a status of SENDING, SENT or ERROR until it gets replaced by
/// the sync event. Using this can display a different sort order of events
/// as the sync event does replace but not relocate the pending event.
bool displayPendingEvent = true,
}) async {
txid ??= client.generateUniqueTransactionId();
sendingFilePlaceholders[txid] = file;
@ -1035,6 +1048,7 @@ class Room {
editEventId: editEventId,
threadRootEventId: threadRootEventId,
threadLastEventId: threadLastEventId,
displayPendingEvent: displayPendingEvent,
);
sendingFilePlaceholders.remove(txid);
sendingFileThumbnails.remove(txid);
@ -1119,6 +1133,12 @@ class Room {
String? editEventId,
String? threadRootEventId,
String? threadLastEventId,
/// Displays an event in the timeline with the transaction ID as the event
/// ID and a status of SENDING, SENT or ERROR until it gets replaced by
/// the sync event. Using this can display a different sort order of events
/// as the sync event does replace but not relocate the pending event.
bool displayPendingEvent = true,
}) async {
// Create new transaction id
final String messageID;
@ -1223,7 +1243,7 @@ class Room {
// we need to add the transaction ID to the set of events that are currently queued to be sent
// even before the fake sync is called, so that the event constructor can check if the event is in the sending state
sendingQueueEventsByTxId.add(messageID);
await _handleFakeSync(syncUpdate);
if (displayPendingEvent) await _handleFakeSync(syncUpdate);
final completer = Completer();
sendingQueue.add(completer);
while (sendingQueue.first != completer) {
@ -1257,7 +1277,7 @@ class Room {
Logs().w('Problem while sending message', e, s);
syncUpdate.rooms!.join!.values.first.timeline!.events!.first
.unsigned![messageSendingStatusKey] = EventStatus.error.intValue;
await _handleFakeSync(syncUpdate);
if (displayPendingEvent) await _handleFakeSync(syncUpdate);
completer.complete();
sendingQueue.remove(completer);
sendingQueueEventsByTxId.remove(messageID);
@ -1276,7 +1296,7 @@ class Room {
syncUpdate.rooms!.join!.values.first.timeline!.events!.first
.unsigned![messageSendingStatusKey] = EventStatus.sent.intValue;
syncUpdate.rooms!.join!.values.first.timeline!.events!.first.eventId = res;
await _handleFakeSync(syncUpdate);
if (displayPendingEvent) await _handleFakeSync(syncUpdate);
completer.complete();
sendingQueue.remove(completer);
sendingQueueEventsByTxId.remove(messageID);

View File

@ -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,
);
}
}
}
}

View File

@ -91,9 +91,7 @@ class CallMembership {
'scope': scope,
'foci_active': [backend.toJson()],
'device_id': deviceId,
'event_id': eventId,
'expires_ts': expiresTs,
'expires': 7200000, // element compatibiltiy remove asap
'membershipID': membershipId, // sessionId
if (feeds != null) 'feeds': feeds,
};
@ -166,4 +164,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)';
}
}

View File

@ -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,
);
}
}

View File

@ -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,
});
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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}
''',

View File

@ -1,6 +1,6 @@
name: matrix
description: Matrix Dart SDK
version: 2.0.1
version: 3.0.1
homepage: https://famedly.com
repository: https://github.com/famedly/matrix-dart-sdk.git
issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues

File diff suppressed because it is too large Load Diff