feat(ui): Add active call information to the current notification item (#6668)

This commit is contained in:
Valere Fedronic
2026-07-08 11:46:30 +02:00
committed by GitHub
parent 7bf142a9d9
commit ebef773031
15 changed files with 888 additions and 25 deletions

View File

@@ -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`.

View File

@@ -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<matrix_sdk_ui::timeline::TimelineItemContent> 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::<Vec<_>>())
.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<String>,
declined_by: Vec<String>,
active_members: Vec<String>,
call_start_ts_millis: Option<u64>,
is_joined: bool,
},
RoomMembership {
user_id: String,

View File

@@ -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...)

View File

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

View File

@@ -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<OwnedEventId>,
/// 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<ActiveCallInfo>,
}
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<ActiveCallInfo>) -> 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.

View File

@@ -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<BackgroundTaskHandle>,
}
/// 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<OwnedUserId>,
/// 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<MilliSecondsSinceUnixEpoch>,
}
impl ActiveCallInfo {
pub(crate) fn from_info(room_info: RoomInfo, owned_user_id: OwnedUserId) -> Option<Self> {
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<MilliSecondsSinceUnixEpoch>) -> Self {
Self { call_started_ts_millis: timestamp, ..self }
}
}
impl<P: RoomDataProvider> TimelineController<P> {
pub(super) async fn new(
room_data_provider: P,
@@ -402,6 +439,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
internal_id_prefix,
unable_to_decrypt_hook,
is_room_encrypted,
None,
)));
Ok(Self { state, focus, room_data_provider, settings })
@@ -752,6 +790,51 @@ impl<P: RoomDataProvider> TimelineController<P> {
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<ActiveCallInfo>,
) {
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<Raw<AnySyncEphemeralRoomEvent>>,

View File

@@ -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<P: RoomDataProvider> TimelineState<P> {
internal_id_prefix: Option<String>,
unable_to_decrypt_hook: Option<Arc<UtdHookManager>>,
is_room_encrypted: bool,
active_call: Option<ActiveCallInfo>,
) -> Self {
Self {
items: ObservableItems::new(),
@@ -68,7 +69,8 @@ impl<P: RoomDataProvider> TimelineState<P> {
internal_id_prefix,
unable_to_decrypt_hook,
is_room_encrypted,
),
)
.with_active_call_info(active_call),
focus,
_phantom: std::marker::PhantomData,
}

View File

@@ -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.
///

View File

@@ -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<CallIntent>,
/// Users who have declined this call notification
declined_by: Vec<OwnedUserId>,
/// Information about the active call, if this notification is about an
/// active call.
active_call_info: Option<ActiveCallInfo>,
},
}

View File

@@ -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<EventCacheDropHandles>,
_focus_drop_handle: Option<BackgroundTaskHandle>,
_crypto_drop_handles: CryptoDropHandles,

View File

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

View File

@@ -69,6 +69,7 @@ mod polls;
mod reactions;
mod read_receipts;
mod redaction;
mod rtc;
mod shields;
mod virt;

View File

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

View File

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

View File

@@ -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<SyncStateEvent<CallMemberEventContent>> = 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<CallMemberEventContent> {
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<DirectEventContent> {
self.global_account_data(DirectEventContent::default())
@@ -1772,6 +1818,35 @@ impl EventBuilder<RtcNotificationEventContent> {
}
}
impl EventBuilder<CallMemberEventContent> {
/// 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,