From 9105db3d824d85ef94e92e9d461abb1fa4006fae Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 4 Dec 2025 12:55:44 +0100 Subject: [PATCH] feat(room): make load_event_with_relations also load relations when falling back to the network Signed-off-by: Johannes Marbach --- crates/matrix-sdk-ui/CHANGELOG.md | 3 + .../src/timeline/pinned_events_loader.rs | 33 ++- .../integration/timeline/pinned_event.rs | 232 +++++++++++++++++- 3 files changed, 253 insertions(+), 15 deletions(-) diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index c66d45f20..1de45eeda 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to this project will be documented in this file. `EventTimelineItem::from_latest_event`, and `Timeline::latest_event`. See the documentation of `matrix_sdk::latest_event` to learn about the new API. [#5624](https://github.com/matrix-org/matrix-rust-sdk/pull/5624/) +- `Room::load_event_with_relations` now also calls `/relations` to fetch related events when falling back + to network mode after a cache miss. + ([#5930](https://github.com/matrix-org/matrix-rust-sdk/pull/5930)) ## [0.16.0] - 2025-12-04 diff --git a/crates/matrix-sdk-ui/src/timeline/pinned_events_loader.rs b/crates/matrix-sdk-ui/src/timeline/pinned_events_loader.rs index d4d0e5851..f5256e3d8 100644 --- a/crates/matrix-sdk-ui/src/timeline/pinned_events_loader.rs +++ b/crates/matrix-sdk-ui/src/timeline/pinned_events_loader.rs @@ -15,9 +15,15 @@ use std::{fmt::Formatter, sync::Arc}; use futures_util::{StreamExt, stream}; -use matrix_sdk::{BoxFuture, Room, SendOutsideWasm, SyncOutsideWasm, config::RequestConfig}; +use matrix_sdk::{ + BoxFuture, Room, SendOutsideWasm, SyncOutsideWasm, + config::RequestConfig, + room::{IncludeRelations, RelationsOptions}, +}; use matrix_sdk_base::deserialized_responses::TimelineEvent; -use ruma::{EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, events::relation::RelationType}; +use ruma::{ + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, events::relation::RelationType, uint, +}; use thiserror::Error; use tokio::sync::Mutex; use tracing::{debug, warn}; @@ -180,7 +186,28 @@ impl PinnedEventsRoom for Room { } debug!("Loading pinned event {event_id} from HS"); - self.event(event_id, request_config).await.map(|e| (e, Vec::new())) + let event = self.event(event_id, request_config).await?; + + debug!("Loading relations of pinned event {event_id} from HS"); + let mut related_events = Vec::new(); + let mut opts = RelationsOptions { + include_relations: IncludeRelations::AllRelations, + recurse: true, + limit: Some(uint!(1000)), + ..Default::default() + }; + + loop { + let relations = self.relations(event_id.to_owned(), opts.clone()).await?; + related_events.extend(relations.chunk); + if let Some(next_from) = relations.next_batch_token { + opts.from = Some(next_from); + } else { + break; + } + } + + Ok((event, related_events)) }) } diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs b/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs index 1ec204380..7cf2691c5 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs @@ -8,7 +8,7 @@ use matrix_sdk::{ config::SyncSettings, test_utils::{ logged_in_client_with_server, - mocks::{MatrixMockServer, RoomMessagesResponseTemplate}, + mocks::{MatrixMockServer, RoomMessagesResponseTemplate, RoomRelationsResponseTemplate}, }, }; use matrix_sdk_base::deserialized_responses::TimelineEvent; @@ -22,7 +22,7 @@ use ruma::{ EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, RoomId, UserId, assign, event_id, events::{ - AnySyncTimelineEvent, + AnySyncTimelineEvent, AnyTimelineEvent, room::{ encrypted::{ EncryptedEventScheme, MegolmV1AesSha2ContentInit, RoomEncryptedEventContent, @@ -52,15 +52,26 @@ async fn test_new_pinned_events_are_not_added_on_sync() { let room_id = room_id!("!test:localhost"); let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); let event_1 = f .text_msg("in the end") - .event_id(event_id!("$1")) + .event_id(event_id) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); - // Mock /event endpoint for a timeline event + // Mock /event endpoint for event $1. mock_events_endpoint(&server, room_id, vec![event_1]).await; + // Mock /relations for event $1. + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + // Load initial timeline items: a `m.room.pinned_events` with events $1 and $2 // pinned let room = PinnedEventsSync::new(room_id) @@ -103,6 +114,140 @@ async fn test_new_pinned_events_are_not_added_on_sync() { assert_pending!(timeline_stream); } +#[async_test] +async fn test_pinned_event_with_reaction() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!test:localhost"); + + let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); + let event_1 = f + .text_msg("in the end") + .event_id(event_id) + .server_ts(MilliSecondsSinceUnixEpoch::now()) + .into_raw_sync(); + + let reaction = f.reaction(event_id, "👀").into_raw_timeline(); + + // Mock /event endpoint for event $1. + mock_events_endpoint(&server, room_id, vec![event_1]).await; + + // Mock /relations for event $1. + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(vec![reaction])) + .mock_once() + .mount() + .await; + + // Load initial timeline items: a `m.room.pinned_events` with event $1 pinned + let room = PinnedEventsSync::new(room_id) + .with_pinned_event_ids(vec!["$1"]) + .mock_and_sync(&client, &server) + .await + .expect("Room should be synced"); + let timeline = + TimelineBuilder::new(&room).with_focus(pinned_events_focus(100)).build().await.unwrap(); + + assert!( + timeline.live_back_pagination_status().await.is_none(), + "there should be no live back-pagination status for a pinned events timeline" + ); + + // Load timeline items + let (items, mut timeline_stream) = timeline.subscribe().await; + + // Verify that the timeline contains the pinned event and its reaction. + assert_eq!(items.len(), 1 + 1); // event item + a date divider + assert!(items[0].is_date_divider()); + assert_eq!(items[1].as_event().unwrap().content().as_message().unwrap().body(), "in the end"); + let reactions = items[1] + .as_event() + .unwrap() + .content() + .reactions() + .expect("pinned event should have reactions"); + assert_eq!(reactions.len(), 1); + assert!(reactions.get("👀").is_some()); + assert_pending!(timeline_stream); +} + +#[async_test] +async fn test_pinned_event_with_paginated_reactions() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!test:localhost"); + + let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); + let event_1 = f + .text_msg("in the end") + .event_id(event_id) + .server_ts(MilliSecondsSinceUnixEpoch::now()) + .into_raw_sync(); + + let reaction_1 = f.reaction(event_id, "👀").into_raw_timeline(); + let reaction_2 = f.reaction(event_id, "🤔").into_raw_timeline(); + + // Mock /event endpoint for event $1. + mock_events_endpoint(&server, room_id, vec![event_1]).await; + + // Mock /relations for event $1, split across two pages. + let token = "foo"; + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(vec![reaction_1]).next_batch(token)) + .mock_once() + .mount() + .await; + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .match_from(token) + .ok(RoomRelationsResponseTemplate::default().events(vec![reaction_2])) + .mock_once() + .mount() + .await; + + // Load initial timeline items: a `m.room.pinned_events` with event $1 pinned + let room = PinnedEventsSync::new(room_id) + .with_pinned_event_ids(vec!["$1"]) + .mock_and_sync(&client, &server) + .await + .expect("Room should be synced"); + let timeline = + TimelineBuilder::new(&room).with_focus(pinned_events_focus(100)).build().await.unwrap(); + + assert!( + timeline.live_back_pagination_status().await.is_none(), + "there should be no live back-pagination status for a pinned events timeline" + ); + + // Load timeline items + let (items, mut timeline_stream) = timeline.subscribe().await; + + // Verify that the timeline contains the pinned event and its reactions. + assert_eq!(items.len(), 1 + 1); // event item + a date divider + assert!(items[0].is_date_divider()); + assert_eq!(items[1].as_event().unwrap().content().as_message().unwrap().body(), "in the end"); + let reactions = items[1] + .as_event() + .unwrap() + .content() + .reactions() + .expect("pinned event should have reactions"); + assert_eq!(reactions.len(), 2); + assert!(reactions.get("👀").is_some()); + assert!(reactions.get("🤔").is_some()); + assert_pending!(timeline_stream); +} + #[async_test] async fn test_new_pinned_event_ids_reload_the_timeline() { let server = MatrixMockServer::new().await; @@ -110,20 +255,42 @@ async fn test_new_pinned_event_ids_reload_the_timeline() { let room_id = room_id!("!test:localhost"); let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id_1 = event_id!("$1"); let event_1 = f .text_msg("in the end") - .event_id(event_id!("$1")) + .event_id(event_id_1) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); + let event_id_2 = event_id!("$2"); let event_2 = f .text_msg("it doesn't even matter") - .event_id(event_id!("$2")) + .event_id(event_id_2) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); + // Mock /event endpoint for events $1 and $2. + mock_events_endpoint(&server, room_id, vec![event_1.clone(), event_2.clone()]).await; + + // Mock /relations endpoint for events $1 and $2. + server + .mock_room_relations() + .match_target_event(event_id_1.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + server + .mock_room_relations() + .match_target_event(event_id_2.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + // Load initial timeline items: 2 text messages and a `m.room.pinned_events` // with event $1 and $2 pinned - mock_events_endpoint(&server, room_id, vec![event_1.clone(), event_2.clone()]).await; let room = PinnedEventsSync::new(room_id) .with_timeline_events(vec![event_2.clone()]) .with_pinned_event_ids(vec!["$1"]) @@ -225,15 +392,26 @@ async fn test_cached_events_are_kept_for_different_room_instances() { event_cache.subscribe().unwrap(); let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); let pinned_event = f .text_msg("in the end") - .event_id(event_id!("$1")) + .event_id(event_id) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); - // Mock /event for some timeline events + // Mock /event for the pinned event. mock_events_endpoint(&server, room_id, vec![pinned_event]).await; + // Mock /relations for the pinned event. + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + // Load initial timeline items: a `m.room.pinned_events` with event $1 and $2 // pinned let room = PinnedEventsSync::new(room_id) @@ -427,6 +605,14 @@ async fn test_pinned_timeline_with_pinned_utd_on_sync_contains_it() { // Mock encrypted pinned event for which we don't have keys (an UTD) let utd_event = create_utd(room_id, &sender_id, event_id); mock_events_endpoint(&server, room_id, vec![utd_event]).await; + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; let timeline = TimelineBuilder::new(&room).with_focus(pinned_events_focus(1)).build().await.unwrap(); @@ -445,15 +631,26 @@ async fn test_edited_events_are_reflected_in_sync() { let room_id = room_id!("!test:localhost"); let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); let pinned_event = f .text_msg("in the end") - .event_id(event_id!("$1")) + .event_id(event_id) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); - // Mock /event for some timeline events. + // Mock /event for the pinned event. mock_events_endpoint(&server, room_id, vec![pinned_event]).await; + // Mock /relations for the pinned event. + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + // Load initial timeline items: a text message and a `m.room.pinned_events` with // event $1. let room = PinnedEventsSync::new(room_id) @@ -517,15 +714,26 @@ async fn test_redacted_events_are_reflected_in_sync() { let room_id = room_id!("!test:localhost"); let f = EventFactory::new().room(room_id).sender(*BOB); + let event_id = event_id!("$1"); let pinned_event = f .text_msg("in the end") .event_id(event_id!("$1")) .server_ts(MilliSecondsSinceUnixEpoch::now()) .into_raw_sync(); - // Mock /event for some timeline events + // Mock /event for the pinned timeline event. mock_events_endpoint(&server, room_id, vec![pinned_event]).await; + // Mock /relations for pinned timeline event. + server + .mock_room_relations() + .match_target_event(event_id.to_owned()) + .match_limit(1000) + .ok(RoomRelationsResponseTemplate::default().events(Vec::>::new())) + .mock_once() + .mount() + .await; + // Load initial timeline items: a text message and a `m.room.pinned_events` with // event $1 let room = PinnedEventsSync::new(room_id)