diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs index 630e8089275..39f90f8d3a0 100644 --- a/crates/matrix-sdk-ui/src/timeline/error.rs +++ b/crates/matrix-sdk-ui/src/timeline/error.rs @@ -20,6 +20,8 @@ use matrix_sdk::{ }; use thiserror::Error; +use crate::timeline::pinned_events_loader::PinnedEventsLoaderError; + /// Errors specific to the timeline. #[derive(Error, Debug)] #[non_exhaustive] @@ -60,6 +62,10 @@ pub enum Error { #[error("An error happened during pagination.")] PaginationError(#[from] PaginationError), + /// An error happened during pagination. + #[error("An error happened when loading pinned events.")] + PinnedEventsError(#[from] PinnedEventsLoaderError), + /// An error happened while operating the room's send queue. #[error(transparent)] SendQueueError(#[from] RoomSendQueueError), diff --git a/crates/matrix-sdk-ui/tests/integration/main.rs b/crates/matrix-sdk-ui/tests/integration/main.rs index 1e8c2f319d9..ab70707d1bc 100644 --- a/crates/matrix-sdk-ui/tests/integration/main.rs +++ b/crates/matrix-sdk-ui/tests/integration/main.rs @@ -83,6 +83,22 @@ async fn mock_context( .await; } +/// Mocks the /event endpoint +#[allow(clippy::too_many_arguments)] // clippy you've got such a fixed mindset +async fn mock_event( + server: &MockServer, + room_id: &RoomId, + event_id: &EventId, + event: TimelineEvent, +) { + Mock::given(method("GET")) + .and(path(format!("/_matrix/client/r0/rooms/{room_id}/event/{event_id}"))) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(event.event.json())) + .mount(server) + .await; +} + /// Mocks the /messages endpoint. /// /// Note: pass `chunk` in the correct order: topological for forward pagination, diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs b/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs new file mode 100644 index 00000000000..95d9b3b3687 --- /dev/null +++ b/crates/matrix-sdk-ui/tests/integration/timeline/pinned_event.rs @@ -0,0 +1,270 @@ +use std::time::Duration; + +use assert_matches::assert_matches; +use eyeball_im::VectorDiff; +use futures_util::StreamExt; +use matrix_sdk::{ + config::SyncSettings, + sync::SyncResponse, + test_utils::{events::EventFactory, logged_in_client_with_server}, + Client, +}; +use matrix_sdk_test::{async_test, JoinedRoomBuilder, StateTestEvent, SyncResponseBuilder, BOB}; +use matrix_sdk_ui::{timeline::TimelineFocus, Timeline}; +use ruma::{event_id, owned_room_id, OwnedEventId, OwnedRoomId}; +use serde_json::json; +use stream_assert::assert_pending; +use wiremock::MockServer; + +use crate::{mock_event, mock_sync}; + +#[async_test] +async fn test_new_pinned_events_are_added_on_sync() { + let mut test_helper = TestHelper::new().await; + let room_id = test_helper.room_id.clone(); + + // Join the room + let _ = test_helper.setup_initial_sync_response().await; + test_helper.server.reset().await; + + // Load initial timeline items: a text message and a `m.room.pinned_events` with + // events $1 and $2 pinned + let _ = test_helper + .setup_sync_response(vec![("$1", "in the end", false)], Some(vec!["$1", "$2"])) + .await; + + let room = test_helper.client.get_room(&room_id).unwrap(); + let timeline = Timeline::builder(&room) + .with_focus(TimelineFocus::PinnedEvents { max_events_to_load: 100 }) + .build() + .await + .unwrap(); + test_helper.server.reset().await; + + assert!( + timeline.live_back_pagination_status().await.is_none(), + "there should be no live back-pagination status for a focused timeline" + ); + + // Load timeline items + let (items, mut timeline_stream) = timeline.subscribe().await; + + assert_eq!(items.len(), 1 + 1); // event item + a day divider + assert!(items[0].is_day_divider()); + assert_eq!(items[1].as_event().unwrap().content().as_message().unwrap().body(), "in the end"); + assert_pending!(timeline_stream); + test_helper.server.reset().await; + + // Load new pinned event contents from sync, $2 was pinned but wasn't available + // before + let _ = test_helper + .setup_sync_response( + vec![("$2", "pinned message!", true), ("$3", "normal message", true)], + None, + ) + .await; + + // The list is reloaded, so it's reset + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::Clear); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushBack { value } => { + assert_eq!(value.as_event().unwrap().event_id().unwrap(), event_id!("$1")); + }); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushBack { value } => { + assert_eq!(value.as_event().unwrap().event_id().unwrap(), event_id!("$2")); + }); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushFront { value } => { + assert!(value.is_day_divider()); + }); + assert_pending!(timeline_stream); + test_helper.server.reset().await; +} + +#[async_test] +async fn test_new_pinned_event_ids_reload_the_timeline() { + let mut test_helper = TestHelper::new().await; + let room_id = test_helper.room_id.clone(); + + // Join the room + let _ = test_helper.setup_initial_sync_response().await; + test_helper.server.reset().await; + + // Load initial timeline items: 2 text messages and a `m.room.pinned_events` + // with event $1 and $2 pinned + let _ = test_helper + .setup_sync_response( + vec![("$1", "in the end", false), ("$2", "it doesn't even matter", true)], + Some(vec!["$1"]), + ) + .await; + + let room = test_helper.client.get_room(&room_id).unwrap(); + let timeline = Timeline::builder(&room) + .with_focus(TimelineFocus::PinnedEvents { max_events_to_load: 100 }) + .build() + .await + .unwrap(); + + assert!( + timeline.live_back_pagination_status().await.is_none(), + "there should be no live back-pagination status for a focused timeline" + ); + + let (items, mut timeline_stream) = timeline.subscribe().await; + + assert_eq!(items.len(), 1 + 1); // event item + a day divider + assert!(items[0].is_day_divider()); + assert_eq!(items[1].as_event().unwrap().content().as_message().unwrap().body(), "in the end"); + assert_pending!(timeline_stream); + test_helper.server.reset().await; + + // Reload timeline with new pinned event ids + let _ = test_helper + .setup_sync_response( + vec![("$1", "in the end", false), ("$2", "it doesn't even matter", false)], + Some(vec!["$1", "$2"]), + ) + .await; + + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::Clear); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushBack { value } => { + assert_eq!(value.as_event().unwrap().event_id().unwrap(), event_id!("$1")); + }); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushBack { value } => { + assert_eq!(value.as_event().unwrap().event_id().unwrap(), event_id!("$2")); + }); + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::PushFront { value } => { + assert!(value.is_day_divider()); + }); + assert_pending!(timeline_stream); + test_helper.server.reset().await; + + // Reload timeline with no pinned event ids + let _ = test_helper + .setup_sync_response( + vec![("$1", "in the end", false), ("$2", "it doesn't even matter", false)], + Some(Vec::new()), + ) + .await; + + assert_matches!(timeline_stream.next().await.unwrap(), VectorDiff::Clear); + assert_pending!(timeline_stream); + test_helper.server.reset().await; +} + +#[async_test] +async fn test_max_events_to_load_is_honored() { + let mut test_helper = TestHelper::new().await; + let room_id = test_helper.room_id.clone(); + + // Join the room + let _ = test_helper.setup_initial_sync_response().await; + test_helper.server.reset().await; + + // Load initial timeline items: a text message and a `m.room.pinned_events` + // with event $1 and $2 pinned + let _ = test_helper + .setup_sync_response(vec![("$1", "in the end", false)], Some(vec!["$1", "$2"])) + .await; + + let room = test_helper.client.get_room(&room_id).unwrap(); + let timeline = Timeline::builder(&room) + .with_focus(TimelineFocus::PinnedEvents { max_events_to_load: 1 }) + .build() + .await + .unwrap(); + + assert!( + timeline.live_back_pagination_status().await.is_none(), + "there should be no live back-pagination status for a focused timeline" + ); + + let (items, mut timeline_stream) = timeline.subscribe().await; + + assert!(items.is_empty()); // We're only taking the last event id, `$2`, and it's not available + assert_pending!(timeline_stream); + test_helper.server.reset().await; +} + +struct TestHelper { + pub client: Client, + pub server: MockServer, + pub room_id: OwnedRoomId, + pub sync_settings: SyncSettings, + pub sync_response_builder: SyncResponseBuilder, +} + +impl TestHelper { + async fn new() -> Self { + let (client, server) = logged_in_client_with_server().await; + Self { + client, + server, + room_id: owned_room_id!("!a98sd12bjh:example.org"), + sync_settings: SyncSettings::new().timeout(Duration::from_millis(3000)), + sync_response_builder: SyncResponseBuilder::new(), + } + } + + async fn setup_initial_sync_response(&mut self) -> Result { + let joined_room_builder = JoinedRoomBuilder::new(&self.room_id) + // Set up encryption + .add_state_event(StateTestEvent::Encryption); + + // Mark the room as joined. + let json_response = self + .sync_response_builder + .add_joined_room(joined_room_builder) + .build_json_sync_response(); + mock_sync(&self.server, json_response, None).await; + self.client.sync_once(self.sync_settings.clone()).await + } + + async fn setup_sync_response( + &mut self, + text_messages: Vec<(&str, &str, bool)>, + pinned_event_ids: Option>, + ) -> Result { + let mut joined_room_builder = JoinedRoomBuilder::new(&self.room_id); + for (id, txt, add_to_timeline) in text_messages { + let event_id: OwnedEventId = id.try_into().unwrap(); + let f = EventFactory::new().room(&self.room_id); + let event_builder = f.text_msg(txt).event_id(&event_id).sender(*BOB); + mock_event(&self.server, &self.room_id, &event_id, event_builder.into_timeline()).await; + + if add_to_timeline { + let event_builder = f.text_msg(txt).event_id(&event_id).sender(*BOB); + joined_room_builder = joined_room_builder + .add_timeline_event(event_builder.into_raw_timeline().cast()); + } + } + + if let Some(pinned_event_ids) = pinned_event_ids { + let pinned_event_ids: Vec = + pinned_event_ids.into_iter().map(|id| id.to_owned()).collect(); + joined_room_builder = + joined_room_builder.add_state_event(StateTestEvent::Custom(json!( + { + "content": { + "pinned": pinned_event_ids + }, + "event_id": "$15139375513VdeRF:localhost", + "origin_server_ts": 151393755, + "sender": "@example:localhost", + "state_key": "", + "type": "m.room.pinned_events", + "unsigned": { + "age": 703422 + } + } + ))) + } + + // Mark the room as joined. + let json_response = self + .sync_response_builder + .add_joined_room(joined_room_builder) + .build_json_sync_response(); + mock_sync(&self.server, json_response, None).await; + self.client.sync_once(self.sync_settings.clone()).await + } +} diff --git a/testing/matrix-sdk-test/src/test_json/sync_events.rs b/testing/matrix-sdk-test/src/test_json/sync_events.rs index 1822d576302..e5f5d824b29 100644 --- a/testing/matrix-sdk-test/src/test_json/sync_events.rs +++ b/testing/matrix-sdk-test/src/test_json/sync_events.rs @@ -320,7 +320,7 @@ pub static NAME_STRIPPED: Lazy = Lazy::new(|| { pub static PINNED_EVENTS: Lazy = Lazy::new(|| { json!({ "content": { - "pinned": [ "$a" ] + "pinned": [ "$a", "$b" ] }, "event_id": "$15139375513VdeRF:localhost", "origin_server_ts": 151393755,