diff --git a/Cargo.lock b/Cargo.lock index 0debea7fc..4f0a415e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4886,9 +4886,9 @@ dependencies = [ [[package]] name = "ruma" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c63fe7f06396db1480c40cf44a57ad4359847cf6c6b3cdaa67507a00a79bb9" +checksum = "cc39664df66d707506b1dd318c30e600f25ebc1e7feadf4804ae45db67922c53" dependencies = [ "assign", "js_int", @@ -4953,9 +4953,9 @@ dependencies = [ [[package]] name = "ruma-events" -version = "0.27.4" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1784ded2966b990151d7d30a4533b737e1100ca9856178a999f3763a615a1efe" +checksum = "9135a0b84495fabbe46c477a1fcdef181001a215ec4b1f9215320cf980397636" dependencies = [ "as_variant", "indexmap 2.0.2", diff --git a/Cargo.toml b/Cargo.toml index 96e44753b..2062c1d4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ futures-executor = "0.3.21" futures-util = { version = "0.3.26", default-features = false, features = ["alloc"] } http = "0.2.6" itertools = "0.11.0" -ruma = { version = "0.9.0", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids"] } +ruma = { version = "0.9.2", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids", "unstable-msc3401"] } ruma-common = "0.12.0" once_cell = "1.16.0" serde = "1.0.151" diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index 8d9ed2922..bed16d4b9 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -145,6 +145,24 @@ impl Room { self.inner.state().into() } + /// Is there a non expired membership with application "m.call" and scope + /// "m.room" in this room. + pub fn has_active_room_call(&self) -> bool { + self.inner.has_active_room_call() + } + + /// Returns a Vec of userId's that participate in the room call. + /// + /// matrix_rtc memberships with application "m.call" and scope "m.room" are + /// considered. A user can occur twice if they join with two devices. + /// convert to a set depending if the different users are required or the + /// amount of sessions. + /// + /// The vector is ordered by oldest membership user to newest. + pub fn active_room_call_participants(&self) -> Vec { + self.inner.active_room_call_participants().iter().map(|u| u.to_string()).collect() + } + pub fn inviter(&self) -> Option> { if self.inner.state() == RoomState::Invited { RUNTIME.block_on(async move { diff --git a/bindings/matrix-sdk-ffi/src/room_info.rs b/bindings/matrix-sdk-ffi/src/room_info.rs index f90b8002b..cbcb5a6a8 100644 --- a/bindings/matrix-sdk-ffi/src/room_info.rs +++ b/bindings/matrix-sdk-ffi/src/room_info.rs @@ -29,6 +29,8 @@ pub struct RoomInfo { highlight_count: u64, notification_count: u64, user_defined_notification_mode: Option, + has_room_call: bool, + active_room_call_participants: Vec, } impl RoomInfo { @@ -67,6 +69,12 @@ impl RoomInfo { .user_defined_notification_mode() .await .map(Into::into), + has_room_call: room.has_active_room_call(), + active_room_call_participants: room + .active_room_call_participants() + .iter() + .map(|u| u.to_string()) + .collect(), }) } } diff --git a/crates/matrix-sdk-base/src/rooms/mod.rs b/crates/matrix-sdk-base/src/rooms/mod.rs index eb5f03f32..0c6b5b8b7 100644 --- a/crates/matrix-sdk-base/src/rooms/mod.rs +++ b/crates/matrix-sdk-base/src/rooms/mod.rs @@ -3,7 +3,11 @@ mod members; pub(crate) mod normal; -use std::{collections::HashSet, fmt}; +use std::{ + collections::{BTreeMap, HashSet}, + fmt, + hash::Hash, +}; use bitflags::bitflags; pub use members::RoomMember; @@ -11,6 +15,7 @@ pub use normal::{Room, RoomInfo, RoomState, RoomStateFilter}; use ruma::{ assign, events::{ + call::member::CallMemberEventContent, macros::EventContent, room::{ avatar::RoomAvatarEventContent, @@ -95,6 +100,10 @@ pub struct BaseRoomInfo { pub(crate) tombstone: Option>, /// The topic of this room. pub(crate) topic: Option>, + /// All Minimal state events that containing one or more running matrixRTC + /// memberships. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub(crate) rtc_member: BTreeMap>, } impl BaseRoomInfo { @@ -166,6 +175,25 @@ impl BaseRoomInfo { AnySyncStateEvent::RoomPowerLevels(p) => { self.max_power_level = p.power_levels().max().into(); } + AnySyncStateEvent::CallMember(m) => { + let Some(o_ev) = m.as_original() else { + return false; + }; + + // we modify the event so that `origin_sever_ts` gets copied into + // `content.created_ts` + let mut o_ev = o_ev.clone(); + o_ev.content.set_created_ts_if_none(o_ev.origin_server_ts); + + // add the new event. + self.rtc_member + .insert(m.state_key().clone(), SyncStateEvent::Original(o_ev).into()); + + // Remove all events that don't contain any memberships anymore. + self.rtc_member.retain(|_, ev| { + ev.as_original().is_some_and(|o| !o.content.active_memberships(None).is_empty()) + }); + } _ => return false, } @@ -220,6 +248,11 @@ impl BaseRoomInfo { AnyStrippedStateEvent::RoomPowerLevels(p) => { self.max_power_level = p.power_levels().max().into(); } + AnyStrippedStateEvent::CallMember(_) => { + // Ignore stripped call state events. Rooms that are not in Joined or Left state + // wont have call information. + return false; + } _ => return false, } @@ -248,6 +281,8 @@ impl BaseRoomInfo { self.tombstone.as_mut().unwrap().redact(&room_version); } else if self.topic.has_event_id(redacts) { self.topic.as_mut().unwrap().redact(&room_version); + } else { + self.rtc_member.retain(|_, member_event| member_event.event_id() != Some(redacts)); } } } @@ -281,6 +316,7 @@ impl Default for BaseRoomInfo { name: None, tombstone: None, topic: None, + rtc_member: BTreeMap::new(), } } } diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index 979cb1b0c..cc2aef31c 100644 --- a/crates/matrix-sdk-base/src/rooms/normal.rs +++ b/crates/matrix-sdk-base/src/rooms/normal.rs @@ -30,6 +30,7 @@ use ruma::events::AnySyncTimelineEvent; use ruma::{ api::client::sync::sync_events::v3::RoomSummary as RumaSummary, events::{ + call::member::Membership, ignored_user_list::IgnoredUserListEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, room::{ @@ -369,6 +370,24 @@ impl Room { self.inner.read().topic().map(ToOwned::to_owned) } + /// Is there a non expired membership with application "m.call" and scope + /// "m.room" in this room + pub fn has_active_room_call(&self) -> bool { + self.inner.read().has_active_room_call() + } + + /// Returns a Vec of userId's that participate in the room call. + /// + /// matrix_rtc memberships with application "m.call" and scope "m.room" are + /// considered. A user can occur twice if they join with two devices. + /// convert to a set depending if the different users are required or the + /// amount of sessions. + /// + /// The vector is ordered by oldest membership user to newest. + pub fn active_room_call_participants(&self) -> Vec { + self.inner.read().active_room_call_participants() + } + /// Return the cached display name of the room if it was provided via sync, /// or otherwise calculate it, taking into account its name, aliases and /// members. @@ -1016,6 +1035,59 @@ impl RoomInfo { fn topic(&self) -> Option<&str> { Some(&self.base_info.topic.as_ref()?.as_original()?.content.topic) } + + /// Get a list of all the valid (non expired) matrixRTC memberships and + /// associated UserId's in this room. + /// + /// The vector is ordered by oldest membership to newest. + fn active_matrix_rtc_memberships(&self) -> Vec<(OwnedUserId, &Membership)> { + let mut v = self + .base_info + .rtc_member + .iter() + .filter_map(|(user_id, ev)| { + ev.as_original().map(|ev| { + ev.content + .active_memberships(None) + .into_iter() + .map(move |m| (user_id.clone(), m)) + }) + }) + .flatten() + .collect::>(); + v.sort_by_key(|(_, m)| m.created_ts); + v + } + + /// Similar to + /// [`matrix_rtc_memberships`](Self::active_matrix_rtc_memberships) but only + /// returns Memberships with application "m.call" and scope "m.room". + /// + /// The vector is ordered by oldest membership user to newest. + fn active_room_call_memberships(&self) -> Vec<(OwnedUserId, &Membership)> { + self.active_matrix_rtc_memberships() + .into_iter() + .filter(|(_user_id, m)| m.is_room_call()) + .collect() + } + + /// Is there a non expired membership with application "m.call" and scope + /// "m.room" in this room. + pub fn has_active_room_call(&self) -> bool { + !self.active_room_call_memberships().is_empty() + } + + /// Returns a Vec of userId's that participate in the room call. + /// + /// matrix_rtc memberships with application "m.call" and scope "m.room" are + /// considered. A user can occur twice if they join with two devices. + /// convert to a set depending if the different users are required or the + /// amount of sessions. + /// + /// The vector is ordered by oldest membership user to newest. + pub fn active_room_call_participants(&self) -> Vec { + self.active_room_call_memberships().iter().map(|(user_id, _)| user_id.clone()).collect() + } } #[cfg(feature = "experimental-sliding-sync")] @@ -1105,15 +1177,24 @@ impl RoomStateFilter { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{ + ops::Sub, + str::FromStr, + sync::Arc, + time::{Duration, SystemTime}, + }; use assign::assign; #[cfg(feature = "experimental-sliding-sync")] use matrix_sdk_common::deserialized_responses::SyncTimelineEvent; - use matrix_sdk_test::async_test; + use matrix_sdk_test::{async_test, ALICE, BOB, CAROL}; use ruma::{ api::client::sync::sync_events::v3::RoomSummary as RumaSummary, events::{ + call::member::{ + Application, CallApplicationContent, CallMemberEventContent, Focus, LivekitFocus, + Membership, MembershipInit, OriginalSyncCallMemberEvent, + }, room::{ canonical_alias::RoomCanonicalAliasEventContent, member::{ @@ -1122,11 +1203,11 @@ mod tests { }, name::RoomNameEventContent, }, - StateEventType, + AnySyncStateEvent, StateEventType, StateUnsigned, SyncStateEvent, }, room_alias_id, room_id, serde::Raw, - user_id, UserId, + user_id, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, UserId, }; use serde_json::json; @@ -1622,4 +1703,118 @@ mod tests { Raw::from_json_string(json!({ "event_id": event_id }).to_string()).unwrap(), )) } + + fn timestamp(minutes_ago: u32) -> MilliSecondsSinceUnixEpoch { + MilliSecondsSinceUnixEpoch::from_system_time( + SystemTime::now().sub(Duration::from_secs((60 * minutes_ago).into())), + ) + .expect("date out of range") + } + + fn call_member_state_event( + memberships: Vec, + ev_id: &str, + user_id: &UserId, + ) -> AnySyncStateEvent { + let content = CallMemberEventContent::new(memberships); + + AnySyncStateEvent::CallMember(SyncStateEvent::Original(OriginalSyncCallMemberEvent { + content, + event_id: OwnedEventId::from_str(ev_id).unwrap(), + sender: user_id.to_owned(), + // we can simply use now here since this will be dropped when using a MinimalStateEvent + // in the roomInfo + origin_server_ts: timestamp(0), + state_key: user_id.to_owned(), + unsigned: StateUnsigned::new(), + })) + } + + fn membership_for_my_call( + device_id: &str, + membership_id: &str, + minutes_ago: u32, + ) -> Membership { + let application = Application::Call(CallApplicationContent::new( + "my_call_id_1".to_owned(), + ruma::events::call::member::CallScope::Room, + )); + let foci_active = vec![Focus::Livekit(LivekitFocus::new( + "my_call_foci_alias".to_owned(), + "https://lk.org".to_owned(), + ))]; + + assign!( + Membership::from(MembershipInit { + application, + device_id: device_id.to_owned(), + expires: Duration::from_millis(3_600_000), + foci_active, + membership_id: membership_id.to_owned(), + }), + { created_ts: Some(timestamp(minutes_ago)) } + ) + } + + fn receive_state_events(room: &Room, events: Vec<&AnySyncStateEvent>) { + room.inner.update_if(|info| { + let mut res = false; + for ev in events { + res |= info.handle_state_event(ev); + } + res + }); + } + + /// `user_a`: empty memberships + /// `user_b`: one membership + /// `user_c`: two memberships (two devices) + fn create_call_with_member_events_for_user(a: &UserId, b: &UserId, c: &UserId) -> Room { + let (_, room) = make_room(RoomState::Joined); + + let a_empty = call_member_state_event(Vec::new(), "$1234", a); + + // make b 10min old + let m_init_b = membership_for_my_call("0", "0", 1); + let b_one = call_member_state_event(vec![m_init_b], "$12345", b); + + // c1 1min old + let m_init_c1 = membership_for_my_call("0", "0", 10); + // c2 20min old + let m_init_c2 = membership_for_my_call("1", "0", 20); + let c_two = call_member_state_event(vec![m_init_c1, m_init_c2], "$123456", c); + + // Intentionally use a non time sorted receive order. + receive_state_events(&room, vec![&c_two, &a_empty, &b_one]); + + room + } + + #[test] + fn show_correct_active_call_state() { + let room = create_call_with_member_events_for_user(&ALICE, &BOB, &CAROL); + + // This check also tests the ordering. + // We want older events to be in the front. + // user_b (Bob) is 1min old, c1 (CAROL) 10min old, c2 (CAROL) 20min old + assert_eq!( + vec![CAROL.to_owned(), CAROL.to_owned(), BOB.to_owned()], + room.active_room_call_participants() + ); + assert!(room.has_active_room_call()); + } + + #[test] + fn active_call_is_false_when_everyone_left() { + let room = create_call_with_member_events_for_user(&ALICE, &BOB, &CAROL); + + let b_empty_membership = call_member_state_event(Vec::new(), "$1234_1", &BOB); + let c_empty_membership = call_member_state_event(Vec::new(), "$12345_1", &CAROL); + + receive_state_events(&room, vec![&b_empty_membership, &c_empty_membership]); + + // We have no active call anymore after emptying the memberships + assert_eq!(Vec::::new(), room.active_room_call_participants()); + assert!(!room.has_active_room_call()); + } } diff --git a/crates/matrix-sdk-base/src/store/migration_helpers.rs b/crates/matrix-sdk-base/src/store/migration_helpers.rs index 438e43294..f04e41790 100644 --- a/crates/matrix-sdk-base/src/store/migration_helpers.rs +++ b/crates/matrix-sdk-base/src/store/migration_helpers.rs @@ -14,7 +14,7 @@ //! Data migration helpers for StateStore implementations. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; #[cfg(feature = "experimental-sliding-sync")] use matrix_sdk_common::deserialized_responses::SyncTimelineEvent; @@ -199,6 +199,7 @@ impl BaseRoomInfoV1 { name, tombstone, topic, + rtc_member: BTreeMap::new(), } } }