diff --git a/bindings/matrix-sdk-ffi/changelog.d/6668.feature b/bindings/matrix-sdk-ffi/changelog.d/6668.feature new file mode 100644 index 00000000000..15f778fa15c --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6668.feature @@ -0,0 +1,2 @@ +The `TimelineItemContent::RtcNotification` now contains additional fields for when the notification is related to +and active call. The new fields are `active_members` (if not empty then the call is active), `call_start_ts_millis`, `is_joined`. \ No newline at end of file diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index 9a032ab68f9..1e7bea8b33d 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/content.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/content.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; use matrix_sdk::room::power_levels::power_level_user_changes; +use matrix_sdk_base::CallIntentConsensus; use matrix_sdk_ui::timeline::RoomPinnedEventsChange; use ruma::events::{ StateEventContentChange, room::history_visibility::HistoryVisibility as RumaHistoryVisibility, @@ -40,12 +41,31 @@ impl From for TimelineItemContent Content::CallInvite => TimelineItemContent::CallInvite, - Content::RtcNotification { call_intent, declined_by: declinations } => { - TimelineItemContent::RtcNotification { - call_intent: call_intent.map(|s| s.to_string()), - declined_by: declinations.iter().map(|u| u.to_string()).collect(), - } - } + Content::RtcNotification { + call_intent, + declined_by: declinations, + active_call_info, + } => TimelineItemContent::RtcNotification { + // if the call is active use the live intent + call_intent: active_call_info + .as_ref() + .and_then(|a| match &a.call_intent { + CallIntentConsensus::Full(intent) => Some(intent), + CallIntentConsensus::Partial { intent, .. } => Some(intent), + CallIntentConsensus::None => None, + }) + .or(call_intent.as_ref()) + .map(|c| c.to_string()), + declined_by: declinations.iter().map(|u| u.to_string()).collect(), + active_members: active_call_info + .as_ref() + .map(|a| a.active_members.iter().map(|u| u.to_string()).collect::>()) + .unwrap_or_default(), + is_joined: active_call_info.as_ref().map(|a| a.is_joined).unwrap_or(false), + call_start_ts_millis: active_call_info + .and_then(|a| a.call_started_ts_millis) + .map(|it| it.0.into()), + }, Content::MembershipChange(membership) => { let reason = match membership.content() { StateEventContentChange::Original { content, .. } => content.reason.clone(), @@ -166,6 +186,9 @@ pub enum TimelineItemContent { RtcNotification { call_intent: Option, declined_by: Vec, + active_members: Vec, + call_start_ts_millis: Option, + is_joined: bool, }, RoomMembership { user_id: String, diff --git a/crates/matrix-sdk-ui/changelog.d/6668.feature b/crates/matrix-sdk-ui/changelog.d/6668.feature new file mode 100644 index 00000000000..d853911da61 --- /dev/null +++ b/crates/matrix-sdk-ui/changelog.d/6668.feature @@ -0,0 +1,3 @@ +The `TimelineItemContent::RtcNotification` now contains an optional `ActiveCallInfo` struct, which provides information +about the active call associated with the notification. +This allows to render in the timeline more detailed context about the active call (members, join state, etc...) \ No newline at end of file diff --git a/crates/matrix-sdk-ui/src/timeline/builder.rs b/crates/matrix-sdk-ui/src/timeline/builder.rs index 8584ca2f766..e4fec761578 100644 --- a/crates/matrix-sdk-ui/src/timeline/builder.rs +++ b/crates/matrix-sdk-ui/src/timeline/builder.rs @@ -26,8 +26,11 @@ use super::{ use crate::{ timeline::{ TimelineReadReceiptTracking, - controller::{InitFocusResult, spawn_crypto_tasks}, - tasks::{room_event_cache_updates_task, room_send_queue_update_task}, + controller::{ActiveCallInfo, InitFocusResult, spawn_crypto_tasks}, + tasks::{ + room_event_cache_updates_task, room_send_queue_update_task, rtc_membership_update_task, + }, + traits::RoomDataProvider, }, unable_to_decrypt_hook::UtdHookManager, }; @@ -174,6 +177,9 @@ impl TimelineBuilder { .ok() .unwrap_or_default(); + let initial_info = room.clone_info(); + let owned_user_id = room.own_user_id().to_owned(); + let controller = TimelineController::new( room.clone(), &focus, @@ -237,6 +243,28 @@ impl TimelineBuilder { .abort_on_drop() }; + let initial_active_call_info = ActiveCallInfo::from_info(initial_info, owned_user_id); + if initial_active_call_info.is_some() { + controller.handle_active_call_update(initial_active_call_info).await; + } + let rtc_membership_listener_handle = { + let room_info_subscriber = room.subscribe_info(); + room.client() + .task_monitor() + .spawn_infinite_task("timeline::rtc_membership_listener", { + let span = info_span!( + parent: Span::none(), + "rtc_membership_handler", + room_id = ?room.room_id(), + ); + span.follows_from(Span::current()); + + rtc_membership_update_task(room_info_subscriber, controller.clone()) + .instrument(span) + }) + .abort_on_drop() + }; + let crypto_drop_handles = spawn_crypto_tasks(controller.clone()).await; let timeline = Timeline { @@ -245,6 +273,7 @@ impl TimelineBuilder { _crypto_drop_handles: crypto_drop_handles, _room_update_join_handle: room_update_join_handle, _local_echo_listener_handle: local_echo_listener_handle, + _rtc_membership_listener_handle: rtc_membership_listener_handle, _focus_drop_handle: focus_task, _event_cache_drop_handle: event_cache_drop, }), diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 454fcad7a79..4ab217439bb 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -35,8 +35,8 @@ use tracing::trace; use super::{ super::{TimelineItem, TimelineItemKind, TimelineUniqueId, subscriber::skip::SkipCount}, - Aggregation, AggregationKind, Aggregations, AllRemoteEvents, ObservableItemsTransaction, - PendingEdit, PendingEditKind, + ActiveCallInfo, Aggregation, AggregationKind, Aggregations, AllRemoteEvents, + ObservableItemsTransaction, PendingEdit, PendingEditKind, read_receipts::ReadReceipts, }; use crate::{ @@ -127,6 +127,24 @@ pub(in crate::timeline) struct TimelineMetadata { /// /// TODO: move this over to the event cache (see also #3058). pub(super) read_receipts: ReadReceipts, + + /// The event ID of the active RtcNotification item that should have + /// active_members populated. + /// + /// There is no real link to the active room call and a rtc notification + /// event. For example there could be several rtc notifications events for + /// the same call, but we only want to have a single active call tile in + /// the timeline. We achieve this by keeping a link to the latest + /// notification event in the timeline and the `active_call` info will + /// be attached to it. + pub(crate) active_rtc_notification_event_id: Option, + + /// Current active call info for the room. + /// + /// This info is updated everytime the active call membership and intent + /// change. It is cached to be attached dynamically to the latest + /// RtcNotification event inserted in the timeline. + pub(crate) active_call: Option, } impl TimelineMetadata { @@ -152,9 +170,15 @@ impl TimelineMetadata { unable_to_decrypt_hook, internal_id_prefix, is_room_encrypted, + active_rtc_notification_event_id: None, + active_call: None, } } + pub(super) fn with_active_call_info(self, active_call_info: Option) -> Self { + Self { active_call: active_call_info, ..self } + } + pub(super) fn clear(&mut self) { // Note: we don't clear the next internal id to avoid bad cases of stale unique // ids across timeline clears. diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 7689e46cede..8de617c75a2 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -23,7 +23,7 @@ use as_variant::as_variant; use eyeball_im::{VectorDiff, VectorSubscriberStream}; use eyeball_im_util::vector::{FilterMap, VectorObserverExt}; use futures_core::Stream; -use imbl::Vector; +use imbl::{HashSet, Vector}; use matrix_sdk::{ deserialized_responses::TimelineEvent, event_cache::{ @@ -38,7 +38,8 @@ use matrix_sdk::{ #[cfg(test)] use ruma::events::receipt::ReceiptEventContent; use ruma::{ - EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, TransactionId, UserId, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, + TransactionId, UserId, api::client::receipt::create_receipt::v3::ReceiptType as SendReceiptType, events::{ AnyMessageLikeEventContent, AnySyncEphemeralRoomEvent, AnySyncMessageLikeEvent, @@ -99,6 +100,7 @@ mod state_transaction; pub(super) use aggregations::*; pub(super) use decryption_retry_task::{CryptoDropHandles, spawn_crypto_tasks}; +use matrix_sdk_base::{CallIntentConsensus, RoomInfo}; /// Data associated to the current timeline focus. /// @@ -353,6 +355,41 @@ pub(super) struct InitFocusResult { pub focus_task: Option, } +/// Holds the various info about the current call +#[derive(Clone, Debug, PartialEq)] +pub struct ActiveCallInfo { + /// The list of users in the call + pub active_members: HashSet, + /// The consensus intent of the call, audio/video + pub call_intent: CallIntentConsensus, + /// True if the user (with any device) is currently in the call, meaning + /// they have joined and haven't left yet. + pub is_joined: bool, + /// The timestamp of when the call started, in milliseconds since the unix + /// epoch. Currently, this is the origin_server_ts of the rtc.notification + /// event. + pub call_started_ts_millis: Option, +} + +impl ActiveCallInfo { + pub(crate) fn from_info(room_info: RoomInfo, owned_user_id: OwnedUserId) -> Option { + if room_info.has_active_room_call() { + Some(ActiveCallInfo { + active_members: HashSet::from(room_info.active_room_call_participants()), + call_intent: room_info.active_room_call_consensus_intent(), + is_joined: room_info.active_room_call_participants().contains(&owned_user_id), + call_started_ts_millis: None, + }) + } else { + None + } + } + + pub(crate) fn with_start_time(self, timestamp: Option) -> Self { + Self { call_started_ts_millis: timestamp, ..self } + } +} + impl TimelineController

{ pub(super) async fn new( room_data_provider: P, @@ -402,6 +439,7 @@ impl TimelineController

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, + None, ))); Ok(Self { state, focus, room_data_provider, settings }) @@ -752,6 +790,51 @@ impl TimelineController

{ self.state.write().await.handle_fully_read_marker(fully_read_event_id); } + pub(super) async fn handle_active_call_update( + &self, + maybe_active_call: Option, + ) { + let mut state = self.state.write().await; + let mut txn = state.transaction(); + + // Store the current active call info in metadata for new RtcNotification items + txn.meta.active_call = maybe_active_call.clone(); + + if let Some(existing_event_id) = &txn.meta.active_rtc_notification_event_id { + // Clean up the notification event + let last_notification = rfind_event_by_id(&txn.items, existing_event_id); + if let Some((last_idx, last_notification)) = last_notification { + let updated_content = match last_notification.content() { + TimelineItemContent::RtcNotification { + call_intent, + declined_by, + active_call_info: _active_call_info, + } => Some(TimelineItemContent::RtcNotification { + call_intent: call_intent.to_owned(), + declined_by: declined_by.clone(), + active_call_info: maybe_active_call + .clone() + .map(|info| info.with_start_time(last_notification.timestamp.into())), + }), + _ => None, + }; + if let Some(new_content) = updated_content { + let new_event_item = last_notification.inner.with_content(new_content); + let new_timeline_item = + TimelineItem::new(new_event_item, last_notification.internal_id.clone()); + txn.items.replace(last_idx, new_timeline_item); + } + + if maybe_active_call.is_none() { + // There is no active rtc_notification anymore + txn.meta.active_rtc_notification_event_id = None; + } + } + } + + txn.commit(); + } + pub(super) async fn handle_ephemeral_events( &self, events: Vec>, diff --git a/crates/matrix-sdk-ui/src/timeline/controller/state.rs b/crates/matrix-sdk-ui/src/timeline/controller/state.rs index b83664510c3..4e9c7f3d41e 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/state.rs @@ -34,7 +34,7 @@ use super::{ event_item::RemoteEventOrigin, traits::RoomDataProvider, }, - DateDividerMode, TimelineMetadata, TimelineSettings, TimelineStateTransaction, + ActiveCallInfo, DateDividerMode, TimelineMetadata, TimelineSettings, TimelineStateTransaction, observable_items::ObservableItems, }; use crate::{timeline::controller::TimelineFocusKind, unable_to_decrypt_hook::UtdHookManager}; @@ -59,6 +59,7 @@ impl TimelineState

{ internal_id_prefix: Option, unable_to_decrypt_hook: Option>, is_room_encrypted: bool, + active_call: Option, ) -> Self { Self { items: ObservableItems::new(), @@ -68,7 +69,8 @@ impl TimelineState

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, - ), + ) + .with_active_call_info(active_call), focus, _phantom: std::marker::PhantomData, } diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 864cbf3dbd0..5e2e2e8a609 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -60,7 +60,8 @@ use super::{ }; use crate::{ timeline::{ - TimelineUniqueId, controller::aggregations::PendingEdit, event_item::OtherMessageLike, + TimelineUniqueId, algorithms::rfind_event_item, controller::aggregations::PendingEdit, + event_item::OtherMessageLike, }, unable_to_decrypt_hook::UtdHookManager, }; @@ -454,6 +455,7 @@ impl TimelineAction { Self::add_item(TimelineItemContent::RtcNotification { call_intent: c.call_intent, declined_by: Vec::new(), + active_call_info: None, }) } @@ -843,7 +845,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { let sender = &self.ctx.sender; // Find the live start item by sender and matching content. - let target_event_id = super::algorithms::rfind_event_item(self.items, |item| { + let target_event_id = rfind_event_item(self.items, |item| { item.sender() == sender && item.content().as_live_location_state().is_some_and(|s| s.matches_stop(&content)) }) @@ -1004,6 +1006,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { .map(|_| TimelineDetails::from_initial_value(self.ctx.forwarder_profile.clone())); let timestamp = self.ctx.timestamp; + let is_rtc_notification = matches!(content, TimelineItemContent::RtcNotification { .. }); let kind: EventTimelineItemKind = match &self.ctx.flow { Flow::Local { txn_id, send_handle } => LocalEventTimelineItem { @@ -1237,12 +1240,92 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { } } + // Handle RTC notification active members population and cleanup + if is_rtc_notification { + self.apply_active_call_to_last_rtc_notification(); + } + // If we don't have a read marker item, look if we need to add one now. if !self.meta.has_up_to_date_read_marker_item { self.meta.update_read_marker(self.items); } } + /// Ensures that the last RtcNotification in the timeline has the current + /// active call info, and cleans up any previous notification that had + /// active members. + fn apply_active_call_to_last_rtc_notification(&mut self) { + let last_notification = rfind_event_item(self.items, |it| { + matches!(it.content(), TimelineItemContent::RtcNotification { .. }) + }) + .map(|(a, b)| (a, b.internal_id.clone(), b.clone())); + + if let Some((idx, internal_id, last_notification)) = last_notification { + // Is this the same as previously? + if let Some(prev_event_id) = &self.meta.active_rtc_notification_event_id { + if Some(prev_event_id.as_ref()) == last_notification.event_id() { + // then no changes, the newest rtc_notification event have not change + return; + } + + // They are different, clean the old event + // Find the previous notification item and clear its active_members + if let Some((idx, prev_item)) = + rfind_event_item(self.items, |it: &EventTimelineItem| { + it.event_id() == Some(prev_event_id) + }) + && let TimelineItemContent::RtcNotification { + call_intent, + declined_by, + active_call_info, + } = prev_item.content() + { + // Only clear if it has active members (not already empty) + if active_call_info.is_some() { + let new_content = TimelineItemContent::RtcNotification { + call_intent: call_intent.clone(), + declined_by: declined_by.clone(), + active_call_info: None, + }; + let new_event_item = prev_item.inner.with_content(new_content); + let new_timeline_item = + TimelineItem::new(new_event_item, prev_item.internal_id.clone()); + self.items.replace(idx, new_timeline_item); + } + } + } + + // Update the new last notification if needed + if self.meta.active_call.is_some() { + let TimelineItemContent::RtcNotification { call_intent, declined_by, .. } = + last_notification.content() + else { + // Nothing to update + return; + }; + + let new_content = TimelineItemContent::RtcNotification { + call_intent: call_intent.clone(), + declined_by: declined_by.clone(), + active_call_info: self + .meta + .active_call + .clone() + .map(|c| c.with_start_time(Some(self.ctx.timestamp))), + }; + + let updated_event_item = last_notification.with_content(new_content); + let new_timeline_item = TimelineItem::new(updated_event_item, internal_id); + self.items.replace(idx, new_timeline_item); + + // Update the active_rtc_notification_event_id so that this event gets any new + // updates of call membership + self.meta.active_rtc_notification_event_id = + last_notification.event_id().map(ToOwned::to_owned); + } + } + } + /// Try to recycle a local timeline item for the same event, or create a new /// timeline item for it. /// diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index 878169bfdfb..ca7578a8e03 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -80,7 +80,10 @@ pub use self::{ reply::{EmbeddedEvent, InReplyToDetails}, }; use super::ReactionsByKeyBySender; -use crate::timeline::event_handler::{HandleAggregationKind, TimelineAction}; +use crate::timeline::{ + controller::ActiveCallInfo, + event_handler::{HandleAggregationKind, TimelineAction}, +}; /// The content of an [`EventTimelineItem`][super::EventTimelineItem]. #[allow(clippy::large_enum_variant)] @@ -127,6 +130,9 @@ pub enum TimelineItemContent { call_intent: Option, /// Users who have declined this call notification declined_by: Vec, + /// Information about the active call, if this notification is about an + /// active call. + active_call_info: Option, }, } diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 9e26f3c0f66..ac46c6ca1e2 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -886,6 +886,7 @@ impl Timeline { struct TimelineDropHandle { _room_update_join_handle: BackgroundTaskHandle, _local_echo_listener_handle: BackgroundTaskHandle, + _rtc_membership_listener_handle: BackgroundTaskHandle, _event_cache_drop_handle: Arc, _focus_drop_handle: Option, _crypto_drop_handles: CryptoDropHandles, diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index 8cc59f99372..e20721632f5 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -16,6 +16,7 @@ use std::collections::BTreeSet; +use eyeball::Subscriber as EyeballSubscriber; use matrix_sdk::{ event_cache::{ EventFocusThreadMode, EventFocusedCache, EventsOrigin, PinnedEventsCache, RoomEventCache, @@ -23,11 +24,15 @@ use matrix_sdk::{ }, send_queue::RoomSendQueueUpdate, }; +use matrix_sdk_base::RoomInfo; use ruma::OwnedEventId; use tokio::sync::broadcast::{Receiver, error::RecvError}; use tracing::{error, instrument, trace, warn}; -use crate::timeline::{TimelineController, TimelineFocus, event_item::RemoteEventOrigin}; +use crate::timeline::{ + TimelineController, TimelineFocus, controller::ActiveCallInfo, event_item::RemoteEventOrigin, + traits::RoomDataProvider, +}; /// Long-lived task, in the pinned events focus mode, that updates the timeline /// after any changes in the pinned events. @@ -309,3 +314,23 @@ pub(in crate::timeline) async fn room_send_queue_update_task( } } } + +/// Long-lived task that watches RoomInfo for RTC membership changes +/// and updates the active RtcNotification timeline item. +pub(in crate::timeline) async fn rtc_membership_update_task( + mut room_info: EyeballSubscriber, + timeline_controller: TimelineController, +) { + let mut prev_info = None; + let own_user = timeline_controller.room().own_user_id().to_owned(); + + while let Some(info) = room_info.next().await { + let active_call = ActiveCallInfo::from_info(info, own_user.clone()); + // RoomInfo fires for many reasons; only act when the participant + // list actually changed. + if active_call != prev_info { + prev_info = active_call.clone(); + timeline_controller.handle_active_call_update(active_call).await; + } + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index 6a08d67649d..0452ea72009 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -69,6 +69,7 @@ mod polls; mod reactions; mod read_receipts; mod redaction; +mod rtc; mod shields; mod virt; diff --git a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs new file mode 100644 index 00000000000..a3df2bf57ea --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs @@ -0,0 +1,230 @@ +//! Tests for RTC notification timeline items. + +use std::ops::Add; + +use assert_matches::assert_matches; +use eyeball_im::VectorDiff; +use matrix_sdk_base::CallIntentConsensus; +use matrix_sdk_test::{ALICE, BOB, CAROL}; +use ruma::{ + event_id, + events::rtc::notification::{CallIntent, NotificationType}, +}; +use stream_assert::{assert_next_matches, assert_pending}; + +use super::*; +use crate::timeline::{TimelineItemContent, controller::ActiveCallInfo}; + +/// Test that `handle_rtc_members_update` correctly updates the active_members +/// of the RtcNotification timeline item. +#[tokio::test] +async fn test_rtc_members_update() { + let timeline = TestTimeline::new().await; + + let mut stream = timeline.subscribe_events().await; + + let f = &timeline.factory; + + set_active_call_members(&timeline, &[BOB.to_owned()]).await; + + let notification_event = f + .rtc_notification(NotificationType::Ring) + .sender(*ALICE) + .call_intent(CallIntent::Audio) + .event_id(event_id!("$notification_event")); + + timeline.handle_live_event(notification_event).await; + + // This is two steps, first the item is added, then it is updated with the + // active members + assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + let notification_item = + assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + assert_rtc_notification_active_members(notification_item, vec![BOB.to_owned()]); + + // Simulate two members joined the call + set_active_call_members(&timeline, &[BOB.to_owned(), CAROL.to_owned()]).await; + + let notification_item = assert_next_matches!(stream, VectorDiff::Set { value, .. } => value); + assert_rtc_notification_active_members( + notification_item, + vec![BOB.to_owned(), CAROL.to_owned()], + ); + + // Simulate CAROL left + set_active_call_members(&timeline, &[BOB.to_owned()]).await; + + let notification_item = assert_next_matches!(stream, VectorDiff::Set { value, .. } => value); + assert_rtc_notification_active_members(notification_item, vec![BOB.to_owned()]); +} + +/// Test that `handle_rtc_members_update` correctly updates the active_members +/// of the last notification event +#[tokio::test] +async fn test_rtc_members_update_last_only() { + let timeline = TestTimeline::new().await; + let mut stream = timeline.subscribe_events().await; + + let f = &timeline.factory; + + let some_timestamp = SystemTime::now(); + + // =========== + // ACT: Simulate active members in the call + // =========== + set_active_call_members(&timeline, &[BOB.to_owned()]).await; + + // =========== + // ACT: Insert a first notification event + // =========== + timeline + .handle_live_event( + f.rtc_notification(NotificationType::Ring) + .sender(*ALICE) + .call_intent(CallIntent::Audio) + .event_id(event_id!("$notification_event_old")) + .server_ts(MilliSecondsSinceUnixEpoch::from_system_time(some_timestamp).unwrap()), + ) + .await; + + assert_next_matches!(stream, VectorDiff::PushBack { .. }); + + // =========== + // ASSERT: The notification item should be updated with the active members + // =========== + + let notification_item = assert_next_matches!(stream, VectorDiff::Set { value, .. } => value); + let info = assert_rtc_notification_active_members(notification_item, vec![BOB.to_owned()]); + assert_eq!( + info.call_started_ts_millis, + Some(MilliSecondsSinceUnixEpoch::from_system_time(some_timestamp).unwrap()) + ); + + // =========== + // ACT: Insert a new notification event with a newer timestamp. + // =========== + let new_notification_ts = some_timestamp.add(Duration::from_secs(60)); + + timeline + .handle_live_event( + f.rtc_notification(NotificationType::Ring) + .sender(*ALICE) + .call_intent(CallIntent::Audio) + .event_id(event_id!("$notification_event_new")) + .server_ts( + MilliSecondsSinceUnixEpoch::from_system_time(new_notification_ts).unwrap(), + ), + ) + .await; + + // =========== + // ASSERT: As per requirement only the latest notification item in the timeline + // should have the active call info. So ensure the oldest notification item + // is cleared and that the new one has the previously known info + // =========== + + // The notification is first added + assert_next_matches!( + stream, + VectorDiff::PushBack { value } => value + ); + + // The old one is cleared + let old_notif_updated = assert_next_matches!( + stream, + VectorDiff::Set { index: 0, value } => value + ); + assert_eq!( + old_notif_updated.event_id().expect("eventId should be set"), + event_id!("$notification_event_old") + ); + assert_matches!(old_notif_updated.content, TimelineItemContent::RtcNotification { active_call_info: None, .. } => {}); + + // The new one is updated with the previously known info + let item = assert_next_matches!( + stream, + VectorDiff::Set { index: 1, value } => value + ); + let info = assert_rtc_notification_active_members(item, vec![BOB.to_owned()]); + assert_eq!( + info.call_started_ts_millis, + Some(MilliSecondsSinceUnixEpoch::from_system_time(new_notification_ts).unwrap()) + ); + + // =========== + // ACT: Simulate new active members in the call + // =========== + + set_active_call_members(&timeline, &[BOB.to_owned(), CAROL.to_owned()]).await; + + // =========== + // ASSERT: Should only update the latest notification item. + // =========== + + let notification_item = assert_next_matches!(stream, VectorDiff::Set { value, .. } => value); + assert_eq!( + notification_item.event_id().expect("eventId should be set"), + event_id!("$notification_event_new") + ); + assert_rtc_notification_active_members( + notification_item, + vec![BOB.to_owned(), CAROL.to_owned()], + ); + + // Simulate back paginated rtc notification + + let old_notification_ts = some_timestamp.sub(Duration::from_secs(60)); + + timeline + .controller + .handle_remote_events_with_diffs( + vec![VectorDiff::PushFront { + value: f + .rtc_notification(NotificationType::Ring) + .sender(*ALICE) + .call_intent(CallIntent::Audio) + .event_id(event_id!("$notification_event_old")) + .server_ts( + MilliSecondsSinceUnixEpoch::from_system_time(old_notification_ts).unwrap(), + ) + .into_event(), + }], + RemoteEventOrigin::Pagination, + ) + .await; + + // Should only add this notification, and not modify the most recent one + let notification_item = + assert_next_matches!(stream, VectorDiff::PushFront { value, .. } => value); + assert_eq!( + notification_item.event_id().expect("eventId should be set"), + event_id!("$notification_event_old") + ); + + assert_pending!(stream); +} + +#[track_caller] +fn assert_rtc_notification_active_members( + item: EventTimelineItem, + members: Vec, +) -> ActiveCallInfo { + assert_matches!(item.content, TimelineItemContent::RtcNotification { active_call_info: Some(active_call_info), .. } => { + let active_members = &active_call_info.active_members; + assert_eq!(active_members.len(), members.len()); + for member in members { + assert!(active_members.contains(&member)); + } + active_call_info + }) +} + +async fn set_active_call_members(timeline: &TestTimeline, members: &[OwnedUserId]) { + let active_call = ActiveCallInfo { + active_members: members.to_owned().into(), + call_intent: CallIntentConsensus::None, + is_joined: false, + call_started_ts_millis: MilliSecondsSinceUnixEpoch::now().into(), + }; + timeline.controller.handle_active_call_update(Some(active_call)).await; +} diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs index 93ebb973f08..6f0e88c3d3c 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs @@ -6,7 +6,7 @@ use matrix_sdk_test::{ }; use matrix_sdk_ui::timeline::{RoomExt, TimelineItemContent}; use ruma::{ - event_id, + MilliSecondsSinceUnixEpoch, event_id, events::rtc::notification::{CallIntent, NotificationType}, room_id, }; @@ -54,7 +54,8 @@ async fn test_decline_call() { let event_item = message.as_event().unwrap(); assert!(matches!(event_item.content(), TimelineItemContent::RtcNotification { .. })); assert_let!( - TimelineItemContent::RtcNotification { call_intent: _, declined_by } = event_item.content() + TimelineItemContent::RtcNotification { call_intent: _, declined_by, .. } = + event_item.content() ); assert_eq!(declined_by.len(), 0); @@ -64,7 +65,8 @@ async fn test_decline_call() { let event_item = updated_message.as_event().unwrap(); assert_let!( - TimelineItemContent::RtcNotification { call_intent, declined_by } = event_item.content() + TimelineItemContent::RtcNotification { call_intent, declined_by, .. } = + event_item.content() ); assert_eq!(declined_by.len(), 1); assert_eq!(declined_by[0], *BOB); @@ -118,7 +120,8 @@ async fn test_multiple_decline_call() { let event_item = message.as_event().unwrap(); assert!(matches!(event_item.content(), TimelineItemContent::RtcNotification { .. })); assert_let!( - TimelineItemContent::RtcNotification { call_intent: _, declined_by } = event_item.content() + TimelineItemContent::RtcNotification { call_intent: _, declined_by, .. } = + event_item.content() ); assert_eq!(declined_by.len(), 0); @@ -128,7 +131,8 @@ async fn test_multiple_decline_call() { let event_item = updated_message.as_event().unwrap(); assert_let!( - TimelineItemContent::RtcNotification { call_intent: _, declined_by } = event_item.content() + TimelineItemContent::RtcNotification { call_intent: _, declined_by, .. } = + event_item.content() ); assert_eq!(declined_by.len(), 1); assert_eq!(declined_by[0], *BOB); @@ -139,9 +143,281 @@ async fn test_multiple_decline_call() { let event_item = updated_message.as_event().unwrap(); assert_let!( - TimelineItemContent::RtcNotification { call_intent: _, declined_by } = event_item.content() + TimelineItemContent::RtcNotification { call_intent: _, declined_by, .. } = + event_item.content() ); assert_eq!(declined_by.len(), 2); assert_eq!(declined_by[0], *BOB); assert_eq!(declined_by[1], *CAROL); } + +#[async_test] +async fn test_active_call_info() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + let (_, mut timeline_stream) = timeline.subscribe().await; + + let notification_event_id = event_id!("$notify"); + let bob_membership = event_id!("$bob-joined"); + let carol_membership = event_id!("$carol-joined"); + let alice_membership = event_id!("$alice-joined"); + let notification_timestamp = MilliSecondsSinceUnixEpoch::now(); + + let f = EventFactory::new(); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_timeline_event( + f.rtc_notification(NotificationType::Ring) + .sender(&ALICE) + .server_ts(notification_timestamp) + .event_id(notification_event_id), + ) + .add_state_event( + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .event_id(bob_membership), + ) + .add_state_event( + f.call_membership_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .event_id(carol_membership), + ), + ) + .await; + + assert_let_timeout!(Some(timeline_updates) = timeline_stream.next()); + assert_eq!(timeline_updates.len(), 3); + + // First is adding the notification event + assert_let!(VectorDiff::PushBack { value: message } = &timeline_updates[0]); + let event_item = message.as_event().unwrap(); + assert!(matches!(event_item.content(), TimelineItemContent::RtcNotification { .. })); + + //Second update is updating it to have active call info + assert_let!(VectorDiff::Set { index: 0, value: message } = &timeline_updates[1]); + let event_item = message.as_event().unwrap(); + assert!(matches!(event_item.content(), TimelineItemContent::RtcNotification { .. })); + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: Some(active_call_info), .. } = + event_item.content() + ); + let active_members = &active_call_info.active_members; + assert_eq!(active_members.len(), 2); + assert!(active_members.contains(&BOB.to_owned())); + assert!(active_members.contains(&CAROL.to_owned())); + assert_eq!(active_call_info.call_started_ts_millis.unwrap(), notification_timestamp); + + let room_info = room.clone_info(); + assert_eq!(room_info.active_room_call_participants().len(), 2); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_state_event( + f.call_membership_state(ALICE.to_owned(), "ALICE_DEVICE".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .event_id(alice_membership), + ), + ) + .await; + + assert_let_timeout!(Some(timeline_updates) = timeline_stream.next()); + + assert_let!(VectorDiff::Set { index: 1, value: updated_message } = &timeline_updates[0]); + let event_item = updated_message.as_event().unwrap(); + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: Some(active_call_info), .. } = + event_item.content() + ); + let active_members = &active_call_info.active_members; + assert_eq!(active_members.len(), 3); + assert!(active_members.contains(&BOB.to_owned())); + assert!(active_members.contains(&CAROL.to_owned())); + assert!(active_members.contains(&ALICE.to_owned())); + + // Per spec, if there is now a new call notification event, then the active + // call info should now be attached to it and remove from the previous one + let new_notification_event_id = event_id!("$notify_new"); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.rtc_notification(NotificationType::Ring) + .sender(&ALICE) + .event_id(new_notification_event_id), + ), + ) + .await; + + assert_let_timeout!(Some(timeline_updates) = timeline_stream.next()); + + // timeline_updates[0] is read receipt related + assert_let!(VectorDiff::Set { index: 1, .. } = &timeline_updates[0]); + // Update 1 is adding the new notification + assert_let!(VectorDiff::PushBack { .. } = &timeline_updates[1]); + + // Should update the previous notification to clear it and add a new one + assert_let!(VectorDiff::Set { index: 1, value: old_notification } = &timeline_updates[2]); + let event_item = old_notification.as_event().unwrap(); + assert_eq!(event_item.event_id().unwrap(), notification_event_id); + // Active call info should be None + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: None, .. } = event_item.content() + ); + + assert_let!(VectorDiff::Set { index: 2, value: new_notification } = &timeline_updates[3]); + let event_item = new_notification.as_event().unwrap(); + assert_eq!(event_item.event_id().unwrap(), new_notification_event_id); + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: Some(active_call_info), .. } = + event_item.content() + ); + let active_members = &active_call_info.active_members; + assert_eq!(active_members.len(), 3); + assert!(active_members.contains(&BOB.to_owned())); + assert!(active_members.contains(&CAROL.to_owned())); + assert!(active_members.contains(&ALICE.to_owned())); +} + +// There is an order in which the initial events are sent, first the membership +// event is sent, then the notification event is sent. +// When the membership event is received this will trigger an active call info +// update and we don't want that this first update is attached to the current +// last notification in the timeline as it is not the correct one, the correct +// one will come after. If this is not done then there will be a visual glitch +// in the timeline where the last rtc_notification will be updated and then +// replaced by the correct one (it will "jump") +#[async_test] +async fn test_only_update_notification_after_it_has_been_marked_as_last() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + let (_, mut timeline_stream) = timeline.subscribe().await; + + let f = EventFactory::new(); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.rtc_notification(NotificationType::Ring) + .sender(&ALICE) + .event_id(event_id!("$pre-existing-notification")), + ), + ) + .await; + + // A call is started, so first the membership is added + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_state_event( + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .event_id(event_id!("$bob-joined")), + ), + ) + .await; + + // Ensure that the existing notification timeline item has no info about the new + // call + assert_let_timeout!(Some(_timeline_updates) = timeline_stream.next()); + + let items = timeline.items().await; + let event_items: Vec<_> = items.iter().filter_map(|item| item.as_event()).collect(); + let notification = event_items[0]; + + // Assert that active_call_info is none + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: None, .. } = + notification.content() + ); + + // Now the relevant notification is added + let f = EventFactory::new(); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.rtc_notification(NotificationType::Ring) + .sender(&ALICE) + .event_id(event_id!("$call-notification")), + ), + ) + .await; + + assert_let_timeout!(Some(_timeline_updates) = timeline_stream.next()); + + let items = timeline.items().await; + let event_items: Vec<_> = items.iter().filter_map(|item| item.as_event()).collect(); + let notification = event_items[1]; + assert_eq!(notification.event_id().unwrap(), event_id!("$call-notification")); + + // Assert that active_call_info is none + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: Some(active_call_info), .. } = + notification.content() + ); + assert!(active_call_info.active_members.contains(&BOB.to_owned())); + + // Simulate call ending + // A call is started, so first the membership is added + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_state_event( + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .leave() + .event_id(event_id!("$bob-joined")), + ), + ) + .await; + + let items = timeline.items().await; + let event_items: Vec<_> = items.iter().filter_map(|item| item.as_event()).collect(); + let notification = event_items[1]; + + assert_eq!(notification.event_id().unwrap(), event_id!("$call-notification")); + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: None, .. } = + notification.content() + ); + + // If there is a new membership, then this is a new call, should not update the + // old notification + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_state_event( + f.call_membership_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .event_id(event_id!("$carol-joined")), + ), + ) + .await; + + let items = timeline.items().await; + let event_items: Vec<_> = items.iter().filter_map(|item| item.as_event()).collect(); + let notification = event_items[1]; + + assert_eq!(notification.event_id().unwrap(), event_id!("$call-notification")); + assert_let!( + TimelineItemContent::RtcNotification { active_call_info: None, .. } = + notification.content() + ); +} diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 81c72812220..6dc1ffa2920 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -39,7 +39,14 @@ use ruma::{ StaticStateEventContent, StrippedStateEvent, SyncMessageLikeEvent, SyncStateEvent, beacon::BeaconEventContent, beacon_info::BeaconInfoEventContent, - call::{SessionDescription, invite::CallInviteEventContent}, + call::{ + SessionDescription, + invite::CallInviteEventContent, + member::{ + ActiveFocus, ActiveLivekitFocus, Application, CallApplicationContent, + CallMemberEventContent, CallMemberStateKey, CallScope, Focus, LivekitFocus, + }, + }, direct::{DirectEventContent, OwnedDirectUserIdentifier}, fully_read::FullyReadEventContent, ignored_user_list::IgnoredUserListEventContent, @@ -95,6 +102,7 @@ use ruma::{ tag::{TagEventContent, Tags}, typing::TypingEventContent, }, + owned_device_id, presence::PresenceState, push::Ruleset, room::RoomType, @@ -1467,6 +1475,44 @@ impl EventFactory { self.event(RtcDeclineEventContent::new(notification_event_id)) } + /// Creates a rtc membership state event. + /// + /// ``` + /// use matrix_sdk_test::event_factory::EventFactory; + /// use ruma::{ + /// events::{SyncStateEvent, call::member::CallMemberEventContent}, + /// owned_user_id, room_id, + /// serde::Raw, + /// }; + /// + /// let factory = EventFactory::new().room(room_id!("!test:localhost")); + /// + /// let event: Raw> = factory + /// .call_membership_state( + /// owned_user_id!("@alice:localhost"), + /// "ABCDEF".to_owned(), + /// ) + /// .lk_focus("alias".to_owned(), "https://livekit2.com".to_owned()) + /// .into_raw(); + /// ``` + pub fn call_membership_state( + &self, + user_id: OwnedUserId, + device_id: String, + ) -> EventBuilder { + let event = self.event(CallMemberEventContent::new( + Application::Call(CallApplicationContent::new("".to_owned(), CallScope::Room)), + owned_device_id!(device_id.clone()), + ActiveFocus::Livekit(ActiveLivekitFocus::new()), + vec![], + Some(MilliSecondsSinceUnixEpoch::now()), + Some(Duration::from_secs(3600)), + )); + event + .sender(&user_id) + .state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref()) + } + /// Create a new `m.direct` global account data event. pub fn direct(&self) -> EventBuilder { self.global_account_data(DirectEventContent::default()) @@ -1772,6 +1818,35 @@ impl EventBuilder { } } +impl EventBuilder { + /// Sets the livekit focus to the call membership event + pub fn set_livekit_focus(mut self, alias: String, service_url: String) -> Self { + if let CallMemberEventContent::SessionContent(session_data) = &mut self.content { + session_data.foci_preferred = + vec![Focus::Livekit(LivekitFocus::new(alias, service_url))]; + } else { + panic!("focus() called on a non-SessionContent call member event"); + } + self + } + + /// Sets the application for the call membership event. + pub fn application(mut self, call_id: String, scope: CallScope) -> Self { + if let CallMemberEventContent::SessionContent(session_data) = &mut self.content { + session_data.application = + Application::Call(CallApplicationContent::new(call_id, scope)); + } else { + panic!("application() called on a non-SessionContent call member event"); + } + self + } + + /// Make the membership event as a membership event. + pub fn leave(mut self) -> Self { + self.content = CallMemberEventContent::new_empty(None); + self + } +} pub struct ReadReceiptBuilder<'a> { factory: &'a EventFactory, content: ReceiptEventContent,