Skip to content

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

Open
BillCarsonFr wants to merge 9 commits into
mainfrom
valere/rtc/active_call
Open

feat(ui): Add active call information to the current notification item#6668
BillCarsonFr wants to merge 9 commits into
mainfrom
valere/rtc/active_call

Conversation

@BillCarsonFr

Copy link
Copy Markdown
Member

Note for the reviewer

The rtc.notification timeline item is used to represent:

  • A tombstoned call (a past call)
  • Or the current active call in the room

When the rtc.notification is active, we want it to holds the information about the active call, so that client can simply build the UI.

In order to do that a new Optional struct is added to the RTCNotificaiton Content

    /// An `m.rtc.notification` event
    RtcNotification {
        /// The intent of this notification.
        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>,
    },
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>,
}

The timeline controller needs to be aware of the current active call:

  • so I added a new active_call_info as part of the meta data info of timeline controller
  • I created a new task that listen to the room_info changes and updates the meta data with the up-to-date info
  • When the event-handler receives a new rtc-notification, we check for the active call info and update the notification item if it is the most recent

⚠️ Known limitations For now we attached the current call info to the latest rtc.notification in the timeline. We don't have for now a proper way to relate a rtc.notification event from the cluster of active call membership. This problem will be tackled when matrix rtc 2.0 has landed.
For now we use the latest rtc notification item in the timeline (if 2 notifications are sent for the same call, the latest is used).

  • I've documented the public API changes in the appropriate changelog files
    (see Writing changelog entries).
  • This PR was made with the help of AI.

Signed-off-by:

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.91339% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.88%. Comparing base (3862625) to head (412b5df).
⚠️ Report is 12 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
crates/matrix-sdk-ui/src/timeline/event_handler.rs 91.37% 3 Missing and 2 partials ⚠️
...rates/matrix-sdk-ui/src/timeline/controller/mod.rs 93.75% 2 Missing and 1 partial ⚠️
crates/matrix-sdk-ui/src/timeline/tasks.rs 92.30% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #6668    +/-   ##
========================================
  Coverage   89.88%   89.88%            
========================================
  Files         396      396            
  Lines      110236   110361   +125     
  Branches   110236   110361   +125     
========================================
+ Hits        99083    99200   +117     
  Misses       7385     7385            
- Partials     3768     3776     +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Jun 16, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 50 untouched benchmarks


Comparing valere/rtc/active_call (412b5df) with main (7b2b439)

Open in CodSpeed

@BillCarsonFr BillCarsonFr marked this pull request as ready for review June 17, 2026 08:25
@BillCarsonFr BillCarsonFr requested a review from a team as a code owner June 17, 2026 08:25
@BillCarsonFr BillCarsonFr requested review from poljar and removed request for a team June 17, 2026 08:25

@poljar poljar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a bit more work.

Comment on lines +1478 to +1511
// Creates a legacy rtc membership event (state event).
pub fn call_membership_legacy_state(
&self,
user_id: OwnedUserId,
device_id: String,
) -> EventBuilder<CallMemberEventContent> {
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<CallMemberEventContent> {
self.event(CallMemberEventContent::new_empty(None))
.state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two should be a single function and you should add further functions under the EventBuilder<CallMemberEventContent> impl block.

Look at how this is done for the member() function of the EventBuilder.

.state_key(CallMemberStateKey::new(user_id, device_id.into(), true).as_ref())
}

// Creates a legacy rtc membership event (state event).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a doc comment, an example in the doc comment would be welcome as well.

Comment on lines +131 to +133
/// The event ID of the active RTC notification item that should have
/// active_members populated
pub(crate) active_rtc_notification_event_id: Option<OwnedEventId>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? What are active_members, the thing in the ActiveCallInfo?

Is this the event ID which activated a call and contains the list of members which are participating in the call?

If so, isn't ActiveCallInfo the right place for this event ID?

Can we have Some RTC event ID, but None for the active_call field? I think the answer is yes, but not really sure why this would be so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, isn't ActiveCallInfo the right place for this event ID?

It was initially like that but I did a refactor because it didn't fell like it belongs to it.

Currently there is nothing tying an active call to a notification, the trick for now is to use the last notification event in the timeline as the anchor.

When the notification_event_id was part of the ActiveCallInfo, it was initially created with None. Then in the timeline controller when we have the last notification event we had to mutate the ActiveCallInfo to set the event_id now that we know it.
And also at every room_info stream update we were getting a new ActiveCallInfo update with None event_id, so had to mutate it again before saving.
The call_started_ts_millis also suffers from the same problem :/, I was close to remove it from the ActiveCallInfo

flow: Flow::Local { txn_id, send_handle },
should_add_new_items,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I liked this newline.

self.event(RtcDeclineEventContent::new(notification_event_id))
}

// Creates a legacy rtc membership event (state event).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a doc comment.

/// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this potentially expensive? Do we really need to clone all items here? Doesn't seem like it as you're passing a reference to rfind_event_item()?

The variable name confuses me as well. What does binding even mean in this context?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this whole function can be expressed much nicer, a lot of the indentation is noise which we can get rid of using the let Some/else pattern and the last part could be rewritten with let chains to avoid the unreachable as well:

diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs
index 274d40ec73..4c6c9b1f4e 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs
@@ -1212,76 +1212,74 @@
     /// 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 { .. })
-        });
-
-        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"),
+        })
+        .map(|(a, b)| (a, b.internal_id.clone(), b.clone()));
+
+        let Some((idx, internal_id, last_notification)) = last_notification else {
+            return;
+        };
+
+        let Some(prev_event_id) = self.meta.active_rtc_notification_event_id.as_deref() else {
+            return;
+        };
+
+        if Some(prev_event_id.as_ref()) == last_notification.event_id() {
+            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 updated_event_item = last_notification.inner.with_content(new_content);
+                let new_event_item = prev_item.inner.with_content(new_content);
                 let new_timeline_item =
-                    TimelineItem::new(updated_event_item, last_notification.internal_id.clone());
+                    TimelineItem::new(new_event_item, prev_item.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;
             }
         }
+
+        // Update the new last content if needed
+        if self.meta.active_call.is_some()
+            && let TimelineItemContent::RtcNotification {
+                call_intent,
+                declined_by,
+                active_call_info: _,
+            } = last_notification.content()
+        {
+            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

mut room_info: Subscriber<RoomInfo>,
timeline_controller: TimelineController,
) {
// let mut prev_members: Vec<OwnedUserId> = Vec::new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale, commented out, line of code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants