From a49c4cdb69b191282d8f062a8b409512fb41d75a Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 16 Jun 2026 19:10:47 +0200 Subject: [PATCH 01/19] feat(ui): Add active call information to the current notification item --- .../matrix-sdk-ffi/src/timeline/content.rs | 35 ++- crates/matrix-sdk-ui/src/timeline/builder.rs | 31 +- .../src/timeline/controller/metadata.rs | 15 +- .../src/timeline/controller/mod.rs | 101 +++++- .../src/timeline/controller/state.rs | 5 +- .../src/timeline/date_dividers.rs | 9 +- .../src/timeline/event_handler.rs | 88 +++++- .../src/timeline/event_item/content/mod.rs | 8 +- crates/matrix-sdk-ui/src/timeline/mod.rs | 1 + crates/matrix-sdk-ui/src/timeline/tasks.rs | 29 +- .../matrix-sdk-ui/src/timeline/tests/mod.rs | 2 + .../matrix-sdk-ui/src/timeline/tests/rtc.rs | 227 ++++++++++++++ .../tests/integration/timeline/rtc.rs | 290 +++++++++++++++++- testing/matrix-sdk-test/src/event_factory.rs | 44 ++- 14 files changed, 859 insertions(+), 26 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/timeline/tests/rtc.rs diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index 7cbd756e041..dbe7428504e 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/src/timeline/builder.rs b/crates/matrix-sdk-ui/src/timeline/builder.rs index 1df09a0d5b4..3cf47cfbc37 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, }; @@ -171,6 +174,10 @@ impl TimelineBuilder { .ok() .unwrap_or_default(); + let initial_info = room.clone_info(); + let owned_user_id = room.own_user_id().to_owned(); + let active_call = ActiveCallInfo::from_info(initial_info, owned_user_id); + let controller = TimelineController::new( room.clone(), focus.clone(), @@ -178,6 +185,7 @@ impl TimelineBuilder { unable_to_decrypt_hook, is_room_encrypted, settings, + active_call, ); let InitFocusResult { focus_task, has_events } = @@ -233,6 +241,24 @@ impl TimelineBuilder { .abort_on_drop() }; + 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 { @@ -242,6 +268,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 087a06164a5..1027a31b3e1 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -33,8 +33,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::{ @@ -125,6 +125,14 @@ 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 RTC notification item that should have + /// active_members populated + pub(crate) active_rtc_notification_event_id: Option, + + /// Current active call info for the room (used to update the last + /// RtcNotification event content) + pub(crate) active_call: Option, } impl TimelineMetadata { @@ -134,6 +142,7 @@ impl TimelineMetadata { internal_id_prefix: Option, unable_to_decrypt_hook: Option>, is_room_encrypted: bool, + active_call: Option, ) -> Self { Self { subscriber_skip_count: SkipCount::new(), @@ -150,6 +159,8 @@ impl TimelineMetadata { unable_to_decrypt_hook, internal_id_prefix, is_room_encrypted, + active_rtc_notification_event_id: None, + active_call, } } diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 554ac3612a8..b38f1d8e55f 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -22,7 +22,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::{ @@ -37,7 +37,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, @@ -98,6 +99,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. /// @@ -340,6 +342,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) fn new( room_data_provider: P, @@ -348,6 +385,7 @@ impl TimelineController

{ unable_to_decrypt_hook: Option>, is_room_encrypted: bool, settings: TimelineSettings, + active_call: Option, ) -> Self { let focus = match focus { TimelineFocus::Live { hide_threaded_events } => { @@ -378,6 +416,7 @@ impl TimelineController

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, + active_call, ))); Self { state, focus, room_data_provider, settings } @@ -728,6 +767,64 @@ 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 { + println!("handling active call update, existing notif id {}", existing_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 { + println!( + "found last notification event_id {:?} at index {}", + last_notification.event_id(), + last_idx + ); + 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()); + println!( + "replacing last notification with new content at index {}, new item: {:?}", + last_idx, new_timeline_item + ); + 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; + } + } else { + println!("no last notification found"); + } + } + + println!("commit"); + 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 5a4a41908b2..8c5f250f400 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,6 +69,7 @@ impl TimelineState

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, + active_call, ), focus, _phantom: std::marker::PhantomData, @@ -193,7 +195,6 @@ impl TimelineState

{ flow: Flow::Local { txn_id, send_handle }, should_add_new_items, }; - let timeline_action = TimelineAction::from_content(content, in_reply_to, thread_root, None); TimelineEventHandler::new(&mut txn, ctx) .handle_event(&mut date_divider_adjuster, timeline_action, None) diff --git a/crates/matrix-sdk-ui/src/timeline/date_dividers.rs b/crates/matrix-sdk-ui/src/timeline/date_dividers.rs index 6d7b4d8420c..1f771f8f70a 100644 --- a/crates/matrix-sdk-ui/src/timeline/date_dividers.rs +++ b/crates/matrix-sdk-ui/src/timeline/date_dividers.rs @@ -693,7 +693,14 @@ mod tests { } fn test_metadata() -> TimelineMetadata { - TimelineMetadata::new(owned_user_id!("@a:b.c"), RoomVersionRules::V11, None, None, false) + TimelineMetadata::new( + owned_user_id!("@a:b.c"), + RoomVersionRules::V11, + None, + None, + false, + None, + ) } #[test] diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 02ae3c59f1a..274d40ec736 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, }; @@ -419,6 +420,7 @@ impl TimelineAction { Self::add_item(TimelineItemContent::RtcNotification { call_intent: c.call_intent, declined_by: Vec::new(), + active_call_info: None, }) } @@ -797,7 +799,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)) }) @@ -961,6 +963,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 { @@ -1194,12 +1197,93 @@ 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 binding = self.items.clone(); + let last_notification = rfind_event_item(&binding, |it| { + matches!(it.content(), TimelineItemContent::RtcNotification { .. }) + }); + + if let Some((idx, last_notification)) = last_notification { + let last_notification_event_id = last_notification.event_id().map(ToOwned::to_owned); + // Is this the same than previously? + if let Some(prev_event_id) = &self.meta.active_rtc_notification_event_id { + if Some(prev_event_id) == last_notification_event_id.as_ref() { + // 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 content if needed + if self.meta.active_call.is_some() { + let new_content = match last_notification.content() { + TimelineItemContent::RtcNotification { + call_intent, + declined_by, + active_call_info: _, + } => 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))), + }, + _ => unreachable!("Should be a rtc notification"), + }; + + let updated_event_item = last_notification.inner.with_content(new_content); + let new_timeline_item = + TimelineItem::new(updated_event_item, last_notification.internal_id.clone()); + 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; + } + } + } + /// 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 c65d974bd3d..cfb75b101d3 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 @@ -79,7 +79,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)] @@ -126,6 +129,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 53456f7f37d..d7fc1b15d0b 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -889,6 +889,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 a5b23ecea16..9d04f9ad1d4 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; use matrix_sdk::{ event_cache::{ EventFocusThreadMode, EventsOrigin, RoomEventCache, RoomEventCacheSubscriber, @@ -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. @@ -324,3 +329,25 @@ 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: Subscriber, + timeline_controller: TimelineController, +) { + // let mut prev_members: Vec = Vec::new(); + 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 { + println!("## rtc_membership_update_task {active_call:?}"); + 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 840e38de1cf..cb6149abfc4 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -66,6 +66,7 @@ mod polls; mod reactions; mod read_receipts; mod redaction; +mod rtc; mod shields; mod virt; @@ -118,6 +119,7 @@ impl TestTimelineBuilder { self.utd_hook, self.is_room_encrypted, self.settings.unwrap_or_default(), + None, ); TestTimeline { controller, factory: EventFactory::new() } } 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..e32768536ba --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs @@ -0,0 +1,227 @@ +//! 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(); + + let mut stream = timeline.subscribe_events().await; + + let f = &timeline.factory; + + 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; + + let notification_item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + + assert_matches!( + notification_item.content, + TimelineItemContent::RtcNotification { active_call_info: None, .. } + ); + + // 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(); + let mut stream = timeline.subscribe_events().await; + + let f = &timeline.factory; + + let some_timestamp = SystemTime::now(); + + // =========== + // 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 { .. }); + + // =========== + // ACT: Simulate active members in the call + // =========== + set_active_call_members(&timeline, &[BOB.to_owned()]).await; + + // =========== + // 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); +} + +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..81f0e94e7b4 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs @@ -6,12 +6,14 @@ 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, }; use tokio_stream::StreamExt; +use crate::timeline::sliding_sync::timeline_event; + #[async_test] async fn test_decline_call() { let server = MatrixMockServer::new().await; @@ -54,7 +56,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 +67,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 +122,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 +133,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 +145,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_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .sender(&BOB) + .event_id(bob_membership), + ) + .add_state_event( + f.call_membership_legacy_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .sender(&CAROL) + .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_legacy_state(ALICE.to_owned(), "ALICE_DEVICE".to_owned()) + .sender(&ALICE) + .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_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .sender(&BOB) + .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_leave_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .sender(&BOB) + .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_legacy_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .sender(&BOB) + .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..21f87759854 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, 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,40 @@ impl EventFactory { self.event(RtcDeclineEventContent::new(notification_event_id)) } + // Creates a legacy rtc membership event (state event). + pub fn call_membership_legacy_state( + &self, + user_id: OwnedUserId, + device_id: String, + ) -> EventBuilder { + let focus = Focus::Livekit(LivekitFocus::new( + "room2".to_owned(), + "https://livekit2.com".to_owned(), + )); + self.event(CallMemberEventContent::new( + Application::Call(CallApplicationContent::new( + "".to_owned(), + ruma::events::call::member::CallScope::Room, + )), + owned_device_id!(device_id.clone()), + ActiveFocus::Livekit(ActiveLivekitFocus::new()), + vec![focus], + Some(MilliSecondsSinceUnixEpoch::now()), + Some(Duration::from_secs(3600)), + )) + .state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref()) + } + + // Creates a legacy rtc membership event (state event). + pub fn call_membership_leave_legacy_state( + &self, + user_id: OwnedUserId, + device_id: String, + ) -> EventBuilder { + self.event(CallMemberEventContent::new_empty(None)) + .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()) From e780afc65fdd13731230225499b579c3c5b8e18a Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 16 Jun 2026 19:55:18 +0200 Subject: [PATCH 02/19] fixup: test compilation --- crates/matrix-sdk-ui/src/timeline/controller/metadata.rs | 7 +++++-- crates/matrix-sdk-ui/src/timeline/controller/state.rs | 4 ++-- crates/matrix-sdk-ui/src/timeline/date_dividers.rs | 9 +-------- crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs | 2 -- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 1027a31b3e1..b17275fe06a 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -142,7 +142,6 @@ impl TimelineMetadata { internal_id_prefix: Option, unable_to_decrypt_hook: Option>, is_room_encrypted: bool, - active_call: Option, ) -> Self { Self { subscriber_skip_count: SkipCount::new(), @@ -160,10 +159,14 @@ impl TimelineMetadata { internal_id_prefix, is_room_encrypted, active_rtc_notification_event_id: None, - active_call, + 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/state.rs b/crates/matrix-sdk-ui/src/timeline/controller/state.rs index 8c5f250f400..93c7a019ee7 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/state.rs @@ -69,8 +69,8 @@ impl TimelineState

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, - active_call, - ), + ) + .with_active_call_info(active_call), focus, _phantom: std::marker::PhantomData, } diff --git a/crates/matrix-sdk-ui/src/timeline/date_dividers.rs b/crates/matrix-sdk-ui/src/timeline/date_dividers.rs index 1f771f8f70a..6d7b4d8420c 100644 --- a/crates/matrix-sdk-ui/src/timeline/date_dividers.rs +++ b/crates/matrix-sdk-ui/src/timeline/date_dividers.rs @@ -693,14 +693,7 @@ mod tests { } fn test_metadata() -> TimelineMetadata { - TimelineMetadata::new( - owned_user_id!("@a:b.c"), - RoomVersionRules::V11, - None, - None, - false, - None, - ) + TimelineMetadata::new(owned_user_id!("@a:b.c"), RoomVersionRules::V11, None, None, false) } #[test] diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs index 81f0e94e7b4..50b36a84cb7 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs @@ -12,8 +12,6 @@ use ruma::{ }; use tokio_stream::StreamExt; -use crate::timeline::sliding_sync::timeline_event; - #[async_test] async fn test_decline_call() { let server = MatrixMockServer::new().await; From 7303a604353711f69a7f151a862cf6d71c033855 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 17 Jun 2026 09:45:47 +0200 Subject: [PATCH 03/19] fixup: remove unwanted println! --- crates/matrix-sdk-ui/src/timeline/controller/mod.rs | 13 ------------- crates/matrix-sdk-ui/src/timeline/tasks.rs | 1 - 2 files changed, 14 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index b38f1d8e55f..0d6d664bd19 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -778,15 +778,9 @@ impl TimelineController

{ txn.meta.active_call = maybe_active_call.clone(); if let Some(existing_event_id) = &txn.meta.active_rtc_notification_event_id { - println!("handling active call update, existing notif id {}", existing_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 { - println!( - "found last notification event_id {:?} at index {}", - last_notification.event_id(), - last_idx - ); let updated_content = match last_notification.content() { TimelineItemContent::RtcNotification { call_intent, @@ -805,10 +799,6 @@ impl TimelineController

{ 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()); - println!( - "replacing last notification with new content at index {}, new item: {:?}", - last_idx, new_timeline_item - ); txn.items.replace(last_idx, new_timeline_item); } @@ -816,12 +806,9 @@ impl TimelineController

{ // There is no active rtc_notification anymore txn.meta.active_rtc_notification_event_id = None; } - } else { - println!("no last notification found"); } } - println!("commit"); txn.commit(); } diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index 9d04f9ad1d4..f077a846361 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -345,7 +345,6 @@ pub(in crate::timeline) async fn rtc_membership_update_task( // RoomInfo fires for many reasons; only act when the participant // list actually changed. if active_call != prev_info { - println!("## rtc_membership_update_task {active_call:?}"); prev_info = active_call.clone(); timeline_controller.handle_active_call_update(active_call).await; } From 2daa9315b9c49c02ba74f873d72dafec3c563735 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 17 Jun 2026 09:46:08 +0200 Subject: [PATCH 04/19] fix test, now rtc notification is updated in two steps --- .../matrix-sdk-ui/src/timeline/tests/rtc.rs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs index e32768536ba..fd3a79e8ce1 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs @@ -25,6 +25,8 @@ async fn test_rtc_members_update() { let f = &timeline.factory; + set_active_call_members(&timeline, &[BOB.to_owned()]).await; + let notification_event = f .rtc_notification(NotificationType::Ring) .sender(*ALICE) @@ -33,12 +35,12 @@ async fn test_rtc_members_update() { timeline.handle_live_event(notification_event).await; - let notification_item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); - - assert_matches!( - notification_item.content, - TimelineItemContent::RtcNotification { active_call_info: None, .. } - ); + // 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; @@ -67,6 +69,11 @@ async fn test_rtc_members_update_last_only() { 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 // =========== @@ -82,11 +89,6 @@ async fn test_rtc_members_update_last_only() { assert_next_matches!(stream, VectorDiff::PushBack { .. }); - // =========== - // ACT: Simulate active members in the call - // =========== - set_active_call_members(&timeline, &[BOB.to_owned()]).await; - // =========== // ASSERT: The notification item should be updated with the active members // =========== From e3cfa0e42fa34eef7c5bffca96dba43d79d88da0 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 17 Jun 2026 10:24:59 +0200 Subject: [PATCH 05/19] update changelog entries --- bindings/matrix-sdk-ffi/changelog.d/6668.feature | 2 ++ crates/matrix-sdk-ui/changelog.d/6668.feature | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 bindings/matrix-sdk-ffi/changelog.d/6668.feature create mode 100644 crates/matrix-sdk-ui/changelog.d/6668.feature 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/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 From d0edb2e5d02fe191312907ef1660a5ebabfc8f0b Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 18 Jun 2026 13:58:36 +0200 Subject: [PATCH 06/19] test: Use track_caller for extrated assert util --- crates/matrix-sdk-ui/src/timeline/tests/rtc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs index fd3a79e8ce1..b75db6e6f1d 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs @@ -204,6 +204,7 @@ async fn test_rtc_members_update_last_only() { assert_pending!(stream); } +#[track_caller] fn assert_rtc_notification_active_members( item: EventTimelineItem, members: Vec, From cff75f42d1076e472a980227cfac0a1a68ac62fc Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 18 Jun 2026 20:14:33 +0200 Subject: [PATCH 07/19] post merge fix, timeline::new is async --- crates/matrix-sdk-ui/src/timeline/tests/rtc.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs index b75db6e6f1d..a3df2bf57ea 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/rtc.rs @@ -19,7 +19,7 @@ use crate::timeline::{TimelineItemContent, controller::ActiveCallInfo}; /// of the RtcNotification timeline item. #[tokio::test] async fn test_rtc_members_update() { - let timeline = TestTimeline::new(); + let timeline = TestTimeline::new().await; let mut stream = timeline.subscribe_events().await; @@ -62,7 +62,7 @@ async fn test_rtc_members_update() { /// of the last notification event #[tokio::test] async fn test_rtc_members_update_last_only() { - let timeline = TestTimeline::new(); + let timeline = TestTimeline::new().await; let mut stream = timeline.subscribe_events().await; let f = &timeline.factory; From 412b5df3edb9d386314906d51ae4e118dbd22815 Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 19 Jun 2026 11:17:47 +0200 Subject: [PATCH 08/19] post merge: fix too many args in constructor --- crates/matrix-sdk-ui/src/timeline/builder.rs | 6 ++++-- crates/matrix-sdk-ui/src/timeline/controller/mod.rs | 3 +-- crates/matrix-sdk-ui/src/timeline/tests/mod.rs | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/builder.rs b/crates/matrix-sdk-ui/src/timeline/builder.rs index 398d2a3836b..e4fec761578 100644 --- a/crates/matrix-sdk-ui/src/timeline/builder.rs +++ b/crates/matrix-sdk-ui/src/timeline/builder.rs @@ -179,7 +179,6 @@ impl TimelineBuilder { let initial_info = room.clone_info(); let owned_user_id = room.own_user_id().to_owned(); - let active_call = ActiveCallInfo::from_info(initial_info, owned_user_id); let controller = TimelineController::new( room.clone(), @@ -189,7 +188,6 @@ impl TimelineBuilder { unable_to_decrypt_hook, is_room_encrypted, settings, - active_call, ) .await?; @@ -245,6 +243,10 @@ 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() diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 47b3441fd2c..1a6cab7c314 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -399,7 +399,6 @@ impl TimelineController

{ unable_to_decrypt_hook: Option>, is_room_encrypted: bool, settings: TimelineSettings, - active_call: Option, ) -> Result { let room_id = room_data_provider.room_id(); @@ -440,7 +439,7 @@ impl TimelineController

{ internal_id_prefix, unable_to_decrypt_hook, is_room_encrypted, - active_call, + None, ))); Ok(Self { state, focus, room_data_provider, settings }) diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index 8b8550d1873..0452ea72009 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -133,7 +133,6 @@ impl TestTimelineBuilder { self.utd_hook, self.is_room_encrypted, self.settings.unwrap_or_default(), - None, ) .await .unwrap(); From 720a66b7508e8bac838eb0ab42e6e6b0fe19162c Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 29 Jun 2026 17:48:43 +0200 Subject: [PATCH 09/19] review: proper use of the event builder framework --- .../tests/integration/timeline/rtc.rs | 24 +++--- testing/matrix-sdk-test/src/event_factory.rs | 77 +++++++++++++------ 2 files changed, 65 insertions(+), 36 deletions(-) diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs index 50b36a84cb7..7c39f049835 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs @@ -182,13 +182,13 @@ async fn test_active_call_info() { .event_id(notification_event_id), ) .add_state_event( - f.call_membership_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) - .sender(&BOB) + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(bob_membership), ) .add_state_event( - f.call_membership_legacy_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) - .sender(&CAROL) + f.call_membership_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(carol_membership), ), ) @@ -223,8 +223,8 @@ async fn test_active_call_info() { .sync_room( &client, JoinedRoomBuilder::new(room_id).add_state_event( - f.call_membership_legacy_state(ALICE.to_owned(), "ALICE_DEVICE".to_owned()) - .sender(&ALICE) + f.call_membership_state(ALICE.to_owned(), "ALICE_DEVICE".to_owned()) + .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(alice_membership), ), ) @@ -326,8 +326,8 @@ async fn test_only_update_notification_after_it_has_been_marked_as_last() { .sync_room( &client, JoinedRoomBuilder::new(room_id).add_state_event( - f.call_membership_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) - .sender(&BOB) + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(event_id!("$bob-joined")), ), ) @@ -380,8 +380,8 @@ async fn test_only_update_notification_after_it_has_been_marked_as_last() { .sync_room( &client, JoinedRoomBuilder::new(room_id).add_state_event( - f.call_membership_leave_legacy_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) - .sender(&BOB) + f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) + .leave() .event_id(event_id!("$bob-joined")), ), ) @@ -404,8 +404,8 @@ async fn test_only_update_notification_after_it_has_been_marked_as_last() { .sync_room( &client, JoinedRoomBuilder::new(room_id).add_state_event( - f.call_membership_legacy_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) - .sender(&BOB) + f.call_membership_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) + .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(event_id!("$carol-joined")), ), ) diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 21f87759854..0e3bf38b278 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -44,7 +44,7 @@ use ruma::{ invite::CallInviteEventContent, member::{ ActiveFocus, ActiveLivekitFocus, Application, CallApplicationContent, - CallMemberEventContent, CallMemberStateKey, Focus, LivekitFocus, + CallMemberEventContent, CallMemberStateKey, CallScope, Focus, LivekitFocus, }, }, direct::{DirectEventContent, OwnedDirectUserIdentifier}, @@ -1475,37 +1475,36 @@ impl EventFactory { self.event(RtcDeclineEventContent::new(notification_event_id)) } - // Creates a legacy rtc membership event (state event). - pub fn call_membership_legacy_state( + /// Creates a rtc membership state event. + /// + /// ``` + /// use matrix_sdk_test::event_factory::EventFactory; + /// use ruma::{ + /// device_id, events::SyncStateEvent, room_id, serde::Raw, user_id, + /// }; + /// + /// let factory = EventFactory::new().room(room_id!("!test:localhost")); + /// + /// let event: Raw> = factory + /// .call_membership_state(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 focus = Focus::Livekit(LivekitFocus::new( - "room2".to_owned(), - "https://livekit2.com".to_owned(), - )); - self.event(CallMemberEventContent::new( - Application::Call(CallApplicationContent::new( - "".to_owned(), - ruma::events::call::member::CallScope::Room, - )), + 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![focus], + vec![], Some(MilliSecondsSinceUnixEpoch::now()), Some(Duration::from_secs(3600)), - )) - .state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref()) - } - - // Creates a legacy rtc membership event (state event). - pub fn call_membership_leave_legacy_state( - &self, - user_id: OwnedUserId, - device_id: String, - ) -> EventBuilder { - self.event(CallMemberEventContent::new_empty(None)) + )); + event + .sender(&user_id) .state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref()) } @@ -1814,6 +1813,36 @@ impl EventBuilder { } } +impl EventBuilder { + + /// Sets the livekit focus to the call membership event + pub fn lk_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, From d49cc94365d9668aae1d2715b40744ebad96b2a0 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 29 Jun 2026 18:09:15 +0200 Subject: [PATCH 10/19] fixup: example not compiling --- testing/matrix-sdk-test/src/event_factory.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 0e3bf38b278..f721d62da6e 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -1480,13 +1480,14 @@ impl EventFactory { /// ``` /// use matrix_sdk_test::event_factory::EventFactory; /// use ruma::{ - /// device_id, events::SyncStateEvent, room_id, serde::Raw, user_id, + /// 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(user_id!("@alice:localhost")"ABCDEF".to_owned()) + /// .call_membership_state(owned_user_id!("@alice:localhost"), "ABCDEF".to_owned()) /// .lk_focus("alias".to_owned(), "https://livekit2.com".to_owned()) /// .into_raw(); /// ``` From f0ef719c0d2fca5bc4255bf95b41065c6fdc4934 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 29 Jun 2026 18:15:54 +0200 Subject: [PATCH 11/19] fixup style --- testing/matrix-sdk-test/src/event_factory.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index f721d62da6e..f748460b558 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -1481,13 +1481,17 @@ impl EventFactory { /// use matrix_sdk_test::event_factory::EventFactory; /// use ruma::{ /// events::{SyncStateEvent, call::member::CallMemberEventContent}, - /// owned_user_id, room_id, serde::Raw, + /// 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()) + /// .call_membership_state( + /// owned_user_id!("@alice:localhost"), + /// "ABCDEF".to_owned(), + /// ) /// .lk_focus("alias".to_owned(), "https://livekit2.com".to_owned()) /// .into_raw(); /// ``` @@ -1815,7 +1819,6 @@ impl EventBuilder { } impl EventBuilder { - /// Sets the livekit focus to the call membership event pub fn lk_focus(mut self, alias: String, service_url: String) -> Self { if let CallMemberEventContent::SessionContent(session_data) = &mut self.content { From e893aeb2ab7095e9ec72dafa7cff4ebb577fe3cf Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 29 Jun 2026 18:19:52 +0200 Subject: [PATCH 12/19] review: add back new line --- crates/matrix-sdk-ui/src/timeline/controller/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/state.rs b/crates/matrix-sdk-ui/src/timeline/controller/state.rs index 273f37f70f4..3c6ee239507 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/state.rs @@ -195,6 +195,7 @@ impl TimelineState

{ flow: Flow::Local { txn_id, send_handle }, should_add_new_items, }; + let timeline_action = TimelineAction::from_content(content, in_reply_to, thread_root, None); TimelineEventHandler::new(&mut txn, ctx) .handle_event(&mut date_divider_adjuster, timeline_action, None) From 6794d29ee8e024558ec60c0bd49674f7b0bf57f9 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 30 Jun 2026 10:14:54 +0200 Subject: [PATCH 13/19] fixup: post merge fix conflicting import --- crates/matrix-sdk-ui/src/timeline/tasks.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index 2b8e2ce8fd4..50c7a672072 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -16,7 +16,7 @@ use std::collections::BTreeSet; -use eyeball::Subscriber; +use eyeball::Subscriber as EyeballSubscriber; use matrix_sdk::{ event_cache::{ EventFocusThreadMode, EventFocusedCache, EventsOrigin, PinnedEventsCache, RoomEventCache, @@ -318,7 +318,7 @@ 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: Subscriber, + mut room_info: EyeballSubscriber, timeline_controller: TimelineController, ) { // let mut prev_members: Vec = Vec::new(); From ecda8446a5238eea549f6a2c7b9e8c89ed5eddbe Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 30 Jun 2026 14:11:25 +0200 Subject: [PATCH 14/19] review: Avoid unnecessary clones and bad unreachable! --- .../src/timeline/event_handler.rs | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 274d40ec736..1c7cca16fb3 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -1212,16 +1212,15 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { /// active call info, and cleans up any previous notification that had /// active members. fn apply_active_call_to_last_rtc_notification(&mut self) { - let binding = self.items.clone(); - let last_notification = rfind_event_item(&binding, |it| { + 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, last_notification)) = last_notification { - let last_notification_event_id = last_notification.event_id().map(ToOwned::to_owned); + if let Some((idx, internal_id, last_notification)) = last_notification { // Is this the same than previously? if let Some(prev_event_id) = &self.meta.active_rtc_notification_event_id { - if Some(prev_event_id) == last_notification_event_id.as_ref() { + if Some(prev_event_id.as_ref()) == last_notification.event_id() { // then no changes, the newest rtc_notification event have not change return; } @@ -1253,33 +1252,33 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { } } - // Update the new last content if needed + // Update the new last notification if needed if self.meta.active_call.is_some() { - let new_content = match last_notification.content() { - TimelineItemContent::RtcNotification { - call_intent, - declined_by, - active_call_info: _, - } => 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))), - }, - _ => unreachable!("Should be a rtc notification"), + 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.inner.with_content(new_content); - let new_timeline_item = - TimelineItem::new(updated_event_item, last_notification.internal_id.clone()); + 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; + self.meta.active_rtc_notification_event_id = + last_notification.event_id().map(ToOwned::to_owned); } } } From dfaee9589595b730bebedac998339db38a040c14 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 30 Jun 2026 14:14:29 +0200 Subject: [PATCH 15/19] review: remove stale comment --- crates/matrix-sdk-ui/src/timeline/tasks.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index 50c7a672072..e20721632f5 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -321,7 +321,6 @@ pub(in crate::timeline) async fn rtc_membership_update_task( mut room_info: EyeballSubscriber, timeline_controller: TimelineController, ) { - // let mut prev_members: Vec = Vec::new(); let mut prev_info = None; let own_user = timeline_controller.room().own_user_id().to_owned(); From 56c6fe4dcb750881ec8de600682e4be87af73140 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 30 Jun 2026 14:21:46 +0200 Subject: [PATCH 16/19] fixup clippy --- crates/matrix-sdk-ui/src/timeline/event_handler.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 1c7cca16fb3..f6fa963a513 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -1212,13 +1212,13 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { /// 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| { + 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 than previously? + // 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 From b0f3fd6a6ed6a8171a3e9b68222bc5fa31e67d8d Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 6 Jul 2026 14:56:28 +0200 Subject: [PATCH 17/19] review: clarify comments --- .../src/timeline/controller/metadata.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 626e5cfdc00..6afbe097380 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -128,12 +128,21 @@ 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 RTC notification item that should have - /// active_members populated + /// 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 (used to update the last - /// RtcNotification event content) + /// 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, } From e49eecc0616089588a8d68fedc189e04895e020c Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 6 Jul 2026 14:59:41 +0200 Subject: [PATCH 18/19] fixup clippy --- .../src/timeline/controller/metadata.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 6afbe097380..4ab217439bb 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -132,17 +132,18 @@ pub(in crate::timeline) struct TimelineMetadata { /// 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. + /// 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. + /// 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, } From 9b56dfee85e573b4dbe26da047486bebf579b942 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 7 Jul 2026 15:44:40 +0200 Subject: [PATCH 19/19] review: rename `lk_focus` to `set_livekit_focus` --- crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs | 10 +++++----- testing/matrix-sdk-test/src/event_factory.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs index 7c39f049835..6f0e88c3d3c 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/rtc.rs @@ -183,12 +183,12 @@ async fn test_active_call_info() { ) .add_state_event( f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) - .lk_focus(room_id.into(), "https://livekit.localhow".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()) - .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(carol_membership), ), ) @@ -224,7 +224,7 @@ async fn test_active_call_info() { &client, JoinedRoomBuilder::new(room_id).add_state_event( f.call_membership_state(ALICE.to_owned(), "ALICE_DEVICE".to_owned()) - .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(alice_membership), ), ) @@ -327,7 +327,7 @@ async fn test_only_update_notification_after_it_has_been_marked_as_last() { &client, JoinedRoomBuilder::new(room_id).add_state_event( f.call_membership_state(BOB.to_owned(), "BOB_DEVICE".to_owned()) - .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(event_id!("$bob-joined")), ), ) @@ -405,7 +405,7 @@ async fn test_only_update_notification_after_it_has_been_marked_as_last() { &client, JoinedRoomBuilder::new(room_id).add_state_event( f.call_membership_state(CAROL.to_owned(), "CAROL_DEVICE".to_owned()) - .lk_focus(room_id.into(), "https://livekit.localhow".to_owned()) + .set_livekit_focus(room_id.into(), "https://livekit.localhow".to_owned()) .event_id(event_id!("$carol-joined")), ), ) diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index f748460b558..6dc1ffa2920 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -1820,7 +1820,7 @@ impl EventBuilder { impl EventBuilder { /// Sets the livekit focus to the call membership event - pub fn lk_focus(mut self, alias: String, service_url: String) -> Self { + 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))];