diff --git a/bindings/matrix-sdk-ffi/CHANGELOG.md b/bindings/matrix-sdk-ffi/CHANGELOG.md index 19ec85c0d95..5a52d72374b 100644 --- a/bindings/matrix-sdk-ffi/CHANGELOG.md +++ b/bindings/matrix-sdk-ffi/CHANGELOG.md @@ -85,6 +85,7 @@ All notable changes to this project will be documented in this file. ### Changes +- `Timeline::latest_event_id` now uses its `ui::Timeline::latest_event_id` counterpart, instead of getting the latest event from the timeline and then its id.([#5864](https://github.com/matrix-org/matrix-rust-sdk/pull/5864)) - Build Android ARM64 bindings using better default RUSTFLAGS (the same used for iOS ARM64). This should improve performance. [(#5854)](https://github.com/matrix-org/matrix-rust-sdk/pull/5854) ## [0.14.0] - 2025-09-04 diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 635f21cc6e1..1c8a30e2f19 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -365,9 +365,9 @@ impl Timeline { Ok(()) } - /// Returns the [`EventId`] of the latest event in the timeline. + /// Returns the latest [`EventId`] in the timeline. pub async fn latest_event_id(&self) -> Option { - self.inner.latest_event().await.and_then(|event| event.event_id().map(ToString::to_string)) + self.inner.latest_event_id().await.as_deref().map(ToString::to_string) } /// Queues an event in the room's send queue so it's processed for diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index 91ee1313f19..c9e0452d478 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. ### Bug Fixes +- `Timeline::latest_event_id` won't take threaded events into account on live/event focused timelines if `hide_threaded_events` is enabled. This fixes a bug in `Timeline::mark_as_read` that incorrectly tried to send a read receipt for threaded events that aren't really part of those timelines. ([#5864](https://github.com/matrix-org/matrix-rust-sdk/pull/5864/)) - Avoid replacing timeline items when the encryption info is unchanged. ([#5660](https://github.com/matrix-org/matrix-rust-sdk/pull/5660)) - Improvement performance of `RoomList` by introducing a new `RoomListItem` type diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index e78f69ce7ff..c1890670226 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -512,6 +512,10 @@ pub(in crate::timeline) struct EventMeta { /// The ID of the event. pub event_id: OwnedEventId, + /// If this event is part of a thread, this will contain its thread root + /// event id. + pub thread_root_id: Option, + /// Whether the event is among the timeline items. pub visible: bool, @@ -580,7 +584,11 @@ pub(in crate::timeline) struct EventMeta { } impl EventMeta { - pub fn new(event_id: OwnedEventId, visible: bool) -> Self { - Self { event_id, visible, timeline_item_index: None } + pub fn new( + event_id: OwnedEventId, + visible: bool, + thread_root_id: Option, + ) -> Self { + Self { event_id, thread_root_id, visible, timeline_item_index: None } } } diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 342317d2edc..77c053b5047 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -1761,7 +1761,32 @@ impl TimelineController { /// it's folded into another timeline item. pub(crate) async fn latest_event_id(&self) -> Option { let state = self.state.read().await; - state.items.all_remote_events().last().map(|event_meta| &event_meta.event_id).cloned() + let filter_out_thread_events = match self.focus() { + TimelineFocusKind::Thread { .. } => false, + TimelineFocusKind::Live { hide_threaded_events } => hide_threaded_events.to_owned(), + TimelineFocusKind::Event { paginator } => { + paginator.get().is_some_and(|paginator| paginator.hide_threaded_events()) + } + _ => true, + }; + + // In some timelines, threaded events are added to the `AllRemoteEvents` + // collection since they need to be taken into account to calculate read + // receipts, but we don't want to actually take them into account for returning + // the latest event id since they're not visibly in the timeline + state + .items + .all_remote_events() + .iter() + .rev() + .filter_map(|item| { + if !filter_out_thread_events || item.thread_root_id.is_none() { + Some(item.event_id.clone()) + } else { + None + } + }) + .next() } #[instrument(skip(self), fields(room_id = ?self.room().room_id()))] diff --git a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs index 56fdb4ad0ce..e889b01a553 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs @@ -796,7 +796,12 @@ mod observable_items_tests { } fn event_meta(event_id: &str) -> EventMeta { - EventMeta { event_id: event_id.parse().unwrap(), timeline_item_index: None, visible: false } + EventMeta { + event_id: event_id.parse().unwrap(), + thread_root_id: None, + timeline_item_index: None, + visible: false, + } } macro_rules! assert_event_id { @@ -1923,6 +1928,7 @@ impl AllRemoteEvents { } /// Return a reference to the last remote event if it exists. + #[cfg(test)] pub fn last(&self) -> Option<&EventMeta> { self.0.back() } @@ -2054,7 +2060,12 @@ mod all_remote_events_tests { use super::{AllRemoteEvents, EventMeta}; fn event_meta(event_id: &str, timeline_item_index: Option) -> EventMeta { - EventMeta { event_id: event_id.parse().unwrap(), timeline_item_index, visible: false } + EventMeta { + event_id: event_id.parse().unwrap(), + thread_root_id: None, + timeline_item_index, + visible: false, + } } macro_rules! assert_events { diff --git a/crates/matrix-sdk-ui/src/timeline/controller/state_transaction.rs b/crates/matrix-sdk-ui/src/timeline/controller/state_transaction.rs index 928f6ea42c9..db4d5cf0d9e 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/state_transaction.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/state_transaction.rs @@ -476,6 +476,7 @@ impl<'a, P: RoomDataProvider> TimelineStateTransaction<'a, P> { MilliSecondsSinceUnixEpoch, Option, Option, + Option, bool, )> { let state_key: Option = raw.get_field("state_key").ok().flatten(); @@ -534,6 +535,7 @@ impl<'a, P: RoomDataProvider> TimelineStateTransaction<'a, P> { origin_server_ts, transaction_id, Some(TimelineAction::failed_to_parse(event_type, deserialization_error)), + None, true, )) } @@ -550,7 +552,7 @@ impl<'a, P: RoomDataProvider> TimelineStateTransaction<'a, P> { // Remember the event before returning prematurely. // See [`ObservableItems::all_remote_events`]. self.add_or_update_remote_event( - EventMeta::new(event_id, false), + EventMeta::new(event_id, false, None), sender.as_deref(), origin_server_ts, position, @@ -628,63 +630,65 @@ impl<'a, P: RoomDataProvider> TimelineStateTransaction<'a, P> { _ => (event.kind.into_raw(), None), }; - let (event_id, sender, timestamp, txn_id, timeline_action, should_add) = match raw - .deserialize() - { - // Classical path: the event is valid, can be deserialized, everything is alright. - Ok(event) => { - let (in_reply_to, thread_root) = self.meta.process_event_relations( - &event, - &raw, - bundled_edit_encryption_info, - &self.items, - self.focus.is_thread(), - ); - - let should_add = self.should_add_event_item( - room_data_provider, - settings, - &event, - thread_root.as_deref(), - position, - ); - - ( - event.event_id().to_owned(), - event.sender().to_owned(), - event.origin_server_ts(), - event.transaction_id().map(ToOwned::to_owned), - TimelineAction::from_event( - event, + let (event_id, sender, timestamp, txn_id, timeline_action, thread_root, should_add) = + match raw.deserialize() { + // Classical path: the event is valid, can be deserialized, everything is alright. + Ok(event) => { + let (in_reply_to, thread_root) = self.meta.process_event_relations( + &event, &raw, + bundled_edit_encryption_info, + &self.items, + self.focus.is_thread(), + ); + + let should_add = self.should_add_event_item( room_data_provider, - utd_info - .map(|utd_info| (utd_info, self.meta.unable_to_decrypt_hook.as_ref())), - in_reply_to, + settings, + &event, + thread_root.as_deref(), + position, + ); + + ( + event.event_id().to_owned(), + event.sender().to_owned(), + event.origin_server_ts(), + event.transaction_id().map(ToOwned::to_owned), + TimelineAction::from_event( + event, + &raw, + room_data_provider, + utd_info.map(|utd_info| { + (utd_info, self.meta.unable_to_decrypt_hook.as_ref()) + }), + in_reply_to, + thread_root.clone(), + thread_summary, + ) + .await, thread_root, - thread_summary, + should_add, ) - .await, - should_add, - ) - } + } - // The event seems invalid… - Err(e) => { - if let Some(tuple) = - self.maybe_add_error_item(position, room_data_provider, &raw, e, settings).await - { - tuple - } else { - return false; + // The event seems invalid… + Err(e) => { + if let Some(tuple) = self + .maybe_add_error_item(position, room_data_provider, &raw, e, settings) + .await + { + tuple + } else { + return false; + } } - } - }; + }; // Remember the event. // See [`ObservableItems::all_remote_events`]. self.add_or_update_remote_event( - EventMeta::new(event_id.clone(), should_add), + EventMeta::new(event_id.clone(), should_add, thread_root), Some(&sender), Some(timestamp), position, diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 60de8acee31..9febfda9638 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -253,7 +253,7 @@ impl Timeline { Some(item.to_owned()) } - /// Get the latest of the timeline's event items. + /// Get the latest of the timeline's event items, both remote and local. pub async fn latest_event(&self) -> Option { if self.controller.is_live() { self.controller.items().await.iter().rev().find_map(|item| { @@ -268,6 +268,11 @@ impl Timeline { } } + /// Get the latest of the timeline's remote event ids. + pub async fn latest_event_id(&self) -> Option { + self.controller.latest_event_id().await + } + /// Get the current timeline items, along with a stream of updates of /// timeline items. /// diff --git a/crates/matrix-sdk-ui/src/timeline/tests/basic.rs b/crates/matrix-sdk-ui/src/timeline/tests/basic.rs index c4908825c33..00ce8eee6be 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/basic.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/basic.rs @@ -17,8 +17,10 @@ use assert_matches2::assert_let; use eyeball_im::VectorDiff; use futures_util::StreamExt; use imbl::vector; +use matrix_sdk::{assert_next_with_timeout, test_utils::mocks::MatrixMockServer}; +use matrix_sdk_base::ThreadingSupport; use matrix_sdk_test::{ - ALICE, BOB, CAROL, async_test, + ALICE, BOB, CAROL, JoinedRoomBuilder, async_test, event_factory::{EventFactory, PreviousMembership}, }; use ruma::{ @@ -33,14 +35,14 @@ use ruma::{ topic::RedactedRoomTopicEventContent, }, }, - mxc_uri, owned_event_id, owned_mxc_uri, user_id, + mxc_uri, owned_event_id, owned_mxc_uri, room_id, user_id, }; -use stream_assert::assert_next_matches; +use stream_assert::{assert_next_matches, assert_pending}; use super::TestTimeline; use crate::timeline::{ - MembershipChange, MsgLikeContent, MsgLikeKind, TimelineDetails, TimelineItemContent, - TimelineItemKind, VirtualTimelineItem, + MembershipChange, MsgLikeContent, MsgLikeKind, RoomExt, TimelineDetails, TimelineFocus, + TimelineItemContent, TimelineItemKind, VirtualTimelineItem, controller::TimelineSettings, event_item::{AnyOtherFullStateEventContent, RemoteEventOrigin}, tests::{ReadReceiptMap, TestRoomDataProvider, TestTimelineBuilder}, @@ -500,3 +502,88 @@ async fn test_replace_with_initial_events_when_batched() { assert_matches!(value.as_virtual(), Some(VirtualTimelineItem::DateDivider(_))); }); } + +#[async_test] +async fn test_latest_event_id_in_main_timeline() { + let server = MatrixMockServer::new().await; + let client = server + .client_builder() + .on_builder(|b| { + b.with_threading_support(ThreadingSupport::Enabled { with_subscriptions: true }) + }) + .build() + .await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let event_id = event_id!("$message"); + let reaction_event_id = event_id!("$reaction"); + let thread_event_id = event_id!("$thread"); + + let room = server.sync_joined_room(&client, room_id).await; + let timeline = room + .timeline_builder() + .with_focus(TimelineFocus::Live { hide_threaded_events: true }) + .track_read_marker_and_receipts() + .build() + .await + .expect("Could not build live timeline"); + + let (items, mut stream) = timeline.subscribe().await; + assert!(items.is_empty()); + assert_pending!(stream); + + let f = EventFactory::new().room(room_id).sender(user_id!("@a:b.c")); + + // If we receive a message in the live timeline + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_timeline_event(f.text_msg("A message").event_id(event_id).into_raw_sync()), + ) + .await; + + let items = assert_next_with_timeout!(stream); + // We receive the day divider and the items + assert_eq!(items.len(), 2); + assert_let!(Some(latest_event_id) = timeline.controller.latest_event_id().await); + // The latest event id is from the event in the live timeline + assert_eq!(event_id, latest_event_id); + + // If we then receive a reaction + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.reaction(event_id, ":D").event_id(reaction_event_id).into_raw_sync(), + ), + ) + .await; + + let items = assert_next_with_timeout!(stream); + // The reaction is added + assert_eq!(items.len(), 1); + assert_let!(Some(latest_event_id) = timeline.controller.latest_event_id().await); + // And it's now the latest event id + assert_eq!(reaction_event_id, latest_event_id); + + // If we now get a message in a thread inside that room + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("In thread") + .in_thread(event_id, thread_event_id) + .event_id(thread_event_id) + .into_raw_sync(), + ), + ) + .await; + // The thread root event is updated + assert_eq!(items.len(), 1); + assert_let!(Some(latest_event_id) = timeline.controller.latest_event_id().await); + + // But the latest event in the live timeline is still the reaction, since the + // threaded event is not part of the live timeline + assert_eq!(reaction_event_id, latest_event_id); +} diff --git a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs index ca3e3efbb7d..bb0f878f072 100644 --- a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs +++ b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs @@ -2993,6 +2993,8 @@ async fn test_thread_subscriptions_extension_enabled_only_if_server_advertises_i }, }, }; + + mock_server.reset().await; } // Then, advertise support with support for MSC4306; the extension will be @@ -3003,7 +3005,7 @@ async fn test_thread_subscriptions_extension_enabled_only_if_server_advertises_i .mock_versions() .ok_custom(&["v1.11"], &features_map) .named("/versions, second time") - .mock_once() + .up_to_n_times(2) .mount() .await;