diff --git a/crates/matrix-sdk-ui/src/timeline/builder.rs b/crates/matrix-sdk-ui/src/timeline/builder.rs index 04bfcad83fe..3553c2ecd71 100644 --- a/crates/matrix-sdk-ui/src/timeline/builder.rs +++ b/crates/matrix-sdk-ui/src/timeline/builder.rs @@ -158,8 +158,7 @@ impl TimelineBuilder { let (_, mut event_subscriber) = room_event_cache.subscribe().await?; let is_pinned_events = matches!(focus, TimelineFocus::PinnedEvents { .. }); - let is_room_encrypted = - room.is_encrypted().await.map_err(|_| Error::UnknownEncryptionState)?; + let is_room_encrypted = room.is_encrypted().await.ok(); let controller = TimelineController::new( room, @@ -196,6 +195,13 @@ impl TimelineBuilder { None }; + let encryption_changes_handle = spawn({ + let inner = controller.clone(); + async move { + inner.handle_encryption_state_changes().await; + } + }); + let room_update_join_handle = spawn({ let room_event_cache = room_event_cache.clone(); let inner = controller.clone(); @@ -421,6 +427,7 @@ impl TimelineBuilder { room_key_backup_enabled_join_handle, local_echo_listener_handle, _event_cache_drop_handle: event_cache_drop, + encryption_changes_handle, }), }; diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 33e47371fb2..cd78c04e624 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -240,7 +240,7 @@ impl TimelineController

{ focus: TimelineFocus, internal_id_prefix: Option, unable_to_decrypt_hook: Option>, - is_room_encrypted: bool, + is_room_encrypted: Option, ) -> Self { let (focus_data, focus_kind) = match focus { TimelineFocus::Live => (TimelineFocusData::Live, TimelineFocusKind::Live), @@ -345,6 +345,34 @@ impl TimelineController

{ } } + /// Listens to encryption state changes for the room in + /// [`matrix_sdk_base::RoomInfo`] and applies the new value to the + /// existing timeline items. This will then cause a refresh of those + /// timeline items. + pub async fn handle_encryption_state_changes(&self) { + let mut room_info = self.room_data_provider.room_info(); + + while let Some(info) = room_info.next().await { + let changed = { + let state = self.state.read().await; + let mut old_is_room_encrypted = state.meta.is_room_encrypted.write().unwrap(); + let is_encrypted_now = info.is_encrypted(); + + if *old_is_room_encrypted != Some(is_encrypted_now) { + *old_is_room_encrypted = Some(is_encrypted_now); + true + } else { + false + } + }; + + if changed { + let mut state = self.state.write().await; + state.update_all_events_is_room_encrypted(); + } + } + } + pub(crate) async fn reload_pinned_events( &self, ) -> Result, PinnedEventsLoaderError> { diff --git a/crates/matrix-sdk-ui/src/timeline/controller/state.rs b/crates/matrix-sdk-ui/src/timeline/controller/state.rs index ef71404296b..7acef444b2f 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/state.rs @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{collections::VecDeque, future::Future, num::NonZeroUsize, sync::Arc}; +use std::{ + collections::VecDeque, + future::Future, + num::NonZeroUsize, + sync::{Arc, RwLock}, +}; use eyeball_im::{ObservableVector, ObservableVectorTransaction, ObservableVectorTransactionEntry}; use itertools::Itertools as _; @@ -83,7 +88,7 @@ impl TimelineState { room_version: RoomVersionId, internal_id_prefix: Option, unable_to_decrypt_hook: Option>, - is_room_encrypted: bool, + is_room_encrypted: Option, ) -> Self { Self { // Upstream default capacity is currently 16, which is making @@ -295,6 +300,16 @@ impl TimelineState { result } + pub(super) fn update_all_events_is_room_encrypted(&mut self) { + let is_room_encrypted = *self.meta.is_room_encrypted.read().unwrap(); + + // When this transaction finishes, all items in the timeline will be emitted + // again with the updated encryption value + let mut txn = self.transaction(); + txn.update_all_events_is_room_encrypted(is_room_encrypted); + txn.commit(); + } + pub(super) fn transaction(&mut self) -> TimelineStateTransaction<'_> { let items = self.items.transaction(); let meta = self.meta.clone(); @@ -720,6 +735,24 @@ impl TimelineStateTransaction<'_> { fn adjust_day_dividers(&mut self, mut adjuster: DayDividerAdjuster) { adjuster.run(&mut self.items, &mut self.meta); } + + /// This method replaces the `is_room_encrypted` value for all timeline + /// items to its updated version and creates a `VectorDiff::Set` operation + /// for each item which will be added to this transaction. + fn update_all_events_is_room_encrypted(&mut self, is_encrypted: Option) { + for idx in 0..self.items.len() { + let item = &self.items[idx]; + + if let Some(event) = item.as_event() { + let mut cloned_event = event.clone(); + cloned_event.is_room_encrypted = is_encrypted; + + // Replace the existing item with a new version with the right encryption flag + let item = item.with_kind(cloned_event); + self.items.set(idx, item); + } + } + } } #[derive(Clone)] @@ -754,10 +787,7 @@ pub(in crate::timeline) struct TimelineMetadata { /// A boolean indicating whether the room the timeline is attached to is /// actually encrypted or not. - /// TODO: this is misplaced, it should be part of the room provider as this - /// value can change over time when a room switches from non-encrypted - /// to encrypted, see also #3850. - pub(crate) is_room_encrypted: bool, + pub(crate) is_room_encrypted: Arc>>, /// Matrix room version of the timeline's room, or a sensible default. /// @@ -814,7 +844,7 @@ impl TimelineMetadata { room_version: RoomVersionId, internal_id_prefix: Option, unable_to_decrypt_hook: Option>, - is_room_encrypted: bool, + is_room_encrypted: Option, ) -> Self { Self { own_user_id, @@ -831,7 +861,7 @@ impl TimelineMetadata { room_version, unable_to_decrypt_hook, internal_id_prefix, - is_room_encrypted, + is_room_encrypted: Arc::new(RwLock::new(is_room_encrypted)), } } diff --git a/crates/matrix-sdk-ui/src/timeline/day_dividers.rs b/crates/matrix-sdk-ui/src/timeline/day_dividers.rs index ae873e68862..b83d7154220 100644 --- a/crates/matrix-sdk-ui/src/timeline/day_dividers.rs +++ b/crates/matrix-sdk-ui/src/timeline/day_dividers.rs @@ -643,7 +643,13 @@ mod tests { } fn test_metadata() -> TimelineMetadata { - TimelineMetadata::new(owned_user_id!("@a:b.c"), ruma::RoomVersionId::V11, None, None, false) + TimelineMetadata::new( + owned_user_id!("@a:b.c"), + ruma::RoomVersionId::V11, + None, + None, + Some(false), + ) } #[test] diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 40e35e8b5da..7bd1ea253fc 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -402,6 +402,8 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { } TimelineEventKind::OtherState { state_key, content } => { + // Update room encryption if a `m.room.encryption` event is found in the + // timeline if should_add { self.add_item(TimelineItemContent::OtherState(OtherState { state_key, @@ -955,7 +957,11 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { } }; - let is_room_encrypted = self.meta.is_room_encrypted; + let is_room_encrypted = if let Ok(is_room_encrypted) = self.meta.is_room_encrypted.read() { + is_room_encrypted.unwrap_or_default() + } else { + false + }; let mut item = EventTimelineItem::new( sender, diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 58e52de8845..8db2e657a83 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -839,6 +839,7 @@ struct TimelineDropHandle { room_key_backup_enabled_join_handle: JoinHandle<()>, local_echo_listener_handle: JoinHandle<()>, _event_cache_drop_handle: Arc, + encryption_changes_handle: JoinHandle<()>, } impl Drop for TimelineDropHandle { @@ -855,6 +856,7 @@ impl Drop for TimelineDropHandle { self.room_update_join_handle.abort(); self.room_key_from_backups_join_handle.abort(); self.room_key_backup_enabled_join_handle.abort(); + self.encryption_changes_handle.abort(); } } diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index bbc1f0bb6d6..590bf74b669 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -20,6 +20,7 @@ use std::{ sync::Arc, }; +use eyeball::{SharedObservable, Subscriber}; use eyeball_im::VectorDiff; use futures_core::Stream; use futures_util::FutureExt as _; @@ -33,8 +34,8 @@ use matrix_sdk::{ test_utils::events::EventFactory, BoxFuture, }; -use matrix_sdk_base::latest_event::LatestEvent; -use matrix_sdk_test::{EventBuilder, ALICE, BOB}; +use matrix_sdk_base::{latest_event::LatestEvent, RoomInfo, RoomState}; +use matrix_sdk_test::{EventBuilder, ALICE, BOB, DEFAULT_TEST_ROOM_ID}; use ruma::{ event_id, events::{ @@ -103,7 +104,7 @@ impl TestTimeline { TimelineFocus::Live, Some(prefix), None, - false, + Some(false), ), event_builder: EventBuilder::new(), factory: EventFactory::new(), @@ -117,7 +118,7 @@ impl TestTimeline { TimelineFocus::Live, None, None, - false, + Some(false), ), event_builder: EventBuilder::new(), factory: EventFactory::new(), @@ -131,7 +132,7 @@ impl TestTimeline { TimelineFocus::Live, None, Some(hook), - true, + Some(true), ), event_builder: EventBuilder::new(), factory: EventFactory::new(), @@ -146,7 +147,7 @@ impl TestTimeline { TimelineFocus::Live, None, None, - encrypted, + Some(encrypted), ), event_builder: EventBuilder::new(), factory: EventFactory::new(), @@ -444,4 +445,9 @@ impl RoomDataProvider for TestRoomDataProvider { } .boxed() } + + fn room_info(&self) -> Subscriber { + let info = RoomInfo::new(*DEFAULT_TEST_ROOM_ID, RoomState::Joined); + SharedObservable::new(info).subscribe() + } } diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index 49fa44163af..d8c3bd61dc0 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -14,6 +14,7 @@ use std::future::Future; +use eyeball::Subscriber; use futures_util::FutureExt as _; use indexmap::IndexMap; #[cfg(test)] @@ -22,7 +23,7 @@ use matrix_sdk::{ deserialized_responses::TimelineEvent, event_cache::paginator::PaginableRoom, BoxFuture, Result, Room, }; -use matrix_sdk_base::latest_event::LatestEvent; +use matrix_sdk_base::{latest_event::LatestEvent, RoomInfo}; use ruma::{ events::{ fully_read::FullyReadEventContent, @@ -107,6 +108,8 @@ pub(super) trait RoomDataProvider: reason: Option<&'a str>, transaction_id: Option, ) -> BoxFuture<'a, Result<(), super::Error>>; + + fn room_info(&self) -> Subscriber; } impl RoomDataProvider for Room { @@ -271,6 +274,10 @@ impl RoomDataProvider for Room { } .boxed() } + + fn room_info(&self) -> Subscriber { + self.subscribe_info() + } } // Internal helper to make most of retry_event_decryption independent of a room diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs index bd65ec8654e..460badeb2ee 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs @@ -26,14 +26,22 @@ use matrix_sdk_test::{ async_test, mocks::{mock_encryption_state, mock_redaction}, sync_timeline_event, JoinedRoomBuilder, RoomAccountDataTestEvent, StateTestEvent, - SyncResponseBuilder, + SyncResponseBuilder, BOB, +}; +use matrix_sdk_ui::{ + timeline::{ + AnyOtherFullStateEventContent, EventSendState, RoomExt, TimelineItemContent, + VirtualTimelineItem, + }, + RoomListService, Timeline, }; -use matrix_sdk_ui::timeline::{EventSendState, RoomExt, TimelineItemContent, VirtualTimelineItem}; use ruma::{ - event_id, events::room::message::RoomMessageEventContent, room_id, user_id, - MilliSecondsSinceUnixEpoch, + event_id, + events::room::{encryption::RoomEncryptionEventContent, message::RoomMessageEventContent}, + room_id, user_id, MilliSecondsSinceUnixEpoch, }; use serde_json::json; +use stream_assert::{assert_next_matches, assert_pending}; use wiremock::{ matchers::{header, method, path_regex}, Mock, ResponseTemplate, @@ -621,6 +629,95 @@ async fn test_unpin_event_is_returning_an_error() { setup.reset_server().await; } +#[async_test] +async fn test_timeline_without_encryption_info() { + // No encryption is mocked for this client/server pair + let (client, server) = logged_in_client_with_server().await; + let _ = RoomListService::new(client.clone()).await.unwrap(); + + let room_id = room_id!("!a98sd12bjh:example.org"); + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + let f = EventFactory::new().room(room_id).sender(*BOB); + + let mut sync_builder = SyncResponseBuilder::new(); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event(f.text_msg("A message").into_raw_sync()), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let room = client.get_room(room_id).unwrap(); + // Previously this would have panicked. + let timeline = room.timeline().await.unwrap(); + + let (items, _) = timeline.subscribe().await; + assert_eq!(items.len(), 2); + assert!(items[0].as_virtual().is_some()); + // No encryption, no shields + assert!(items[1].as_event().unwrap().get_shield(false).is_none()); +} + +#[async_test] +async fn test_timeline_without_encryption_can_update() { + // No encryption is mocked for this client/server pair + let (client, server) = logged_in_client_with_server().await; + let _ = RoomListService::new(client.clone()).await.unwrap(); + + let room_id = room_id!("!jEsUZKDJdhlrceRyVU:example.org"); + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + let f = EventFactory::new().room(room_id).sender(*BOB); + + let mut sync_builder = SyncResponseBuilder::new(); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event(f.text_msg("A message").into_raw_sync()), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let room = client.get_room(room_id).unwrap(); + // Previously this would have panicked. + // We're creating a timeline without read receipts tracking to check only the + // encryption changes + let timeline = Timeline::builder(&room).build().await.unwrap(); + + let (items, mut stream) = timeline.subscribe().await; + assert_eq!(items.len(), 2); + assert!(items[0].as_virtual().is_some()); + // No encryption, no shields + assert!(items[1].as_event().unwrap().get_shield(false).is_none()); + + let encryption_event_content = RoomEncryptionEventContent::with_recommended_defaults(); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id) + .add_timeline_event(f.event(encryption_event_content).state_key("").into_raw_sync()) + .add_timeline_event(f.text_msg("An encrypted message").into_raw_sync()), + ); + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + // Previous timeline event now has a shield + assert_next_matches!(stream, VectorDiff::Set { index, value } => { + assert_eq!(index, 1); + assert!(value.as_event().unwrap().get_shield(false).is_some()); + }); + // Room encryption event is received + assert_next_matches!(stream, VectorDiff::PushBack { value } => { + assert_let!(TimelineItemContent::OtherState(other_state) = value.as_event().unwrap().content()); + assert_let!(AnyOtherFullStateEventContent::RoomEncryption(_) = other_state.content()); + assert!(value.as_event().unwrap().get_shield(false).is_some()); + }); + // New message event is received and has a shield + assert_next_matches!(stream, VectorDiff::PushBack { value } => { + assert!(value.as_event().unwrap().get_shield(false).is_some()); + }); + assert_pending!(stream); +} + struct PinningTestSetup<'a> { event_id: &'a ruma::EventId, room_id: &'a ruma::RoomId, @@ -647,7 +744,7 @@ impl PinningTestSetup<'_> { Self { event_id, room_id, client, server, sync_settings, sync_builder } } - async fn timeline(&self) -> matrix_sdk_ui::Timeline { + async fn timeline(&self) -> Timeline { mock_encryption_state(&self.server, false).await; let room = self.client.get_room(self.room_id).unwrap(); room.timeline().await.unwrap() diff --git a/crates/matrix-sdk/src/test_utils/events.rs b/crates/matrix-sdk/src/test_utils/events.rs index 36728e29727..ce7b0bb5f1f 100644 --- a/crates/matrix-sdk/src/test_utils/events.rs +++ b/crates/matrix-sdk/src/test_utils/events.rs @@ -57,6 +57,7 @@ pub struct EventBuilder { content: E, server_ts: MilliSecondsSinceUnixEpoch, unsigned: Option, + state_key: Option, } impl EventBuilder @@ -89,6 +90,11 @@ where self } + pub fn state_key(mut self, state_key: impl Into) -> Self { + self.state_key = Some(state_key.into()); + self + } + #[inline(always)] fn construct_json(self, requires_room: bool) -> Raw { let event_id = self @@ -119,6 +125,9 @@ where if let Some(unsigned) = self.unsigned { map.insert("unsigned".to_owned(), json!(unsigned)); } + if let Some(state_key) = self.state_key { + map.insert("state_key".to_owned(), json!(state_key)); + } Raw::new(map).unwrap().cast() } @@ -237,6 +246,7 @@ impl EventFactory { redacts: None, content, unsigned: None, + state_key: None, } }