From a204b2994d4c5d58c4769e767f61fc7db7546e38 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 6 Mar 2024 09:52:23 +0100 Subject: [PATCH 01/13] chore: improve create_room documentation Signed-off-by: Johannes Marbach --- crates/matrix-sdk/src/client/mod.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 4a372ecd5..87c43fd1e 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -1170,12 +1170,10 @@ impl Client { /// # Examples /// /// ```no_run - /// use matrix_sdk::Client; - /// - /// # use matrix_sdk::ruma::api::client::room::{ - /// # create_room::v3::Request as CreateRoomRequest, - /// # Visibility, - /// # }; + /// use matrix_sdk::{ + /// ruma::api::client::room::create_room::v3::Request as CreateRoomRequest, + /// Client, + /// }; /// # use url::Url; /// # /// # async { From f14c00db82539a2e9135b73568c1f68354c47255 Mon Sep 17 00:00:00 2001 From: Benjamin Kampmann Date: Thu, 7 Mar 2024 11:02:32 +0000 Subject: [PATCH 02/13] store: Add a method to set a custom value without reading and returning the old value This is useful if we don't care about the old value, which lets us avoid unnecessary reads. --- crates/matrix-sdk-base/src/store/traits.rs | 24 ++++++++++++++++++++- crates/matrix-sdk-sqlite/src/state_store.rs | 7 ++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-base/src/store/traits.rs b/crates/matrix-sdk-base/src/store/traits.rs index 89caf9f69..8d294db7e 100644 --- a/crates/matrix-sdk-base/src/store/traits.rs +++ b/crates/matrix-sdk-base/src/store/traits.rs @@ -302,7 +302,8 @@ pub trait StateStore: AsyncTraitDeps { /// * `key` - The key to fetch data for async fn get_custom_value(&self, key: &[u8]) -> Result>, Self::Error>; - /// Put arbitrary data into the custom store + /// Put arbitrary data into the custom store, return the data previously + /// stored /// /// # Arguments /// @@ -315,6 +316,27 @@ pub trait StateStore: AsyncTraitDeps { value: Vec, ) -> Result>, Self::Error>; + /// Put arbitrary data into the custom store, do not attempt to read any + /// previous data + /// + /// Optimization option for set_custom_values for stores that would perform + /// better withouts the extra read and the caller not needing that data + /// returned. Otherwise this just wraps around `set_custom_data` and + /// discards the result. + /// + /// # Arguments + /// + /// * `key` - The key to insert data into + /// + /// * `value` - The value to insert + async fn set_custom_value_no_read( + &self, + key: &[u8], + value: Vec, + ) -> Result<(), Self::Error> { + self.set_custom_value(key, value).await.map(|_| ()) + } + /// Remove arbitrary data from the custom store and return it if existed /// /// # Arguments diff --git a/crates/matrix-sdk-sqlite/src/state_store.rs b/crates/matrix-sdk-sqlite/src/state_store.rs index 5ad856a08..211317c3b 100644 --- a/crates/matrix-sdk-sqlite/src/state_store.rs +++ b/crates/matrix-sdk-sqlite/src/state_store.rs @@ -1517,6 +1517,13 @@ impl StateStore for SqliteStateStore { self.acquire().await?.get_kv_blob(self.encode_custom_key(key)).await } + async fn set_custom_value_no_read(&self, key: &[u8], value: Vec) -> Result<()> { + let conn = self.acquire().await?; + let key = self.encode_custom_key(key); + conn.set_kv_blob(key, value).await?; + Ok(()) + } + async fn set_custom_value(&self, key: &[u8], value: Vec) -> Result>> { let conn = self.acquire().await?; let key = self.encode_custom_key(key); From 0469c27b91511d7edd284cd55da7cb96afa8e409 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 7 Mar 2024 12:46:13 +0100 Subject: [PATCH 03/13] ffi: Add methods to get and reset the power levels of a Room - `Room::build_power_level_changes_from_current()` was replaced by `Room::get_power_levels()`, which now returns an SDK/Ruma `RoomPowerLevels` value containing all the data we need to display these values in UI and not only the customised values. - `Room::reset_power_levels()` was added to the FFI layer. --- bindings/matrix-sdk-ffi/src/room.rs | 66 +++++++- crates/matrix-sdk/src/room/mod.rs | 10 ++ .../tests/integration/room/joined.rs | 41 ++++- testing/matrix-sdk-test/src/test_json/sync.rs | 141 ++++++++++++++++++ 4 files changed, 250 insertions(+), 8 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index c0a1af288..73ee3815a 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -10,7 +10,13 @@ use mime::Mime; use ruma::{ api::client::room::report_content, assign, - events::room::{avatar::ImageInfo as RumaAvatarImageInfo, MediaSource}, + events::{ + room::{ + avatar::ImageInfo as RumaAvatarImageInfo, + power_levels::RoomPowerLevels as RumaPowerLevels, MediaSource, + }, + TimelineEventType, + }, EventId, Int, UserId, }; use tokio::sync::RwLock; @@ -561,11 +567,9 @@ impl Room { Ok(()) } - pub async fn build_power_level_changes_from_current( - &self, - ) -> Result { + pub async fn get_power_levels(&self) -> Result { let power_levels = self.inner.room_power_levels().await?; - Ok(power_levels.into()) + Ok(RoomPowerLevels::from(power_levels)) } pub async fn apply_power_level_changes( @@ -603,6 +607,58 @@ impl Room { let user_id = UserId::parse(&user_id)?; Ok(self.inner.get_suggested_user_role(&user_id).await?) } + + pub async fn reset_power_levels(&self) -> Result { + Ok(RoomPowerLevels::from(self.inner.reset_power_levels().await?)) + } +} + +#[derive(uniffi::Record)] +pub struct RoomPowerLevels { + /// The level required to ban a user. + pub ban: i64, + /// The level required to invite a user. + pub invite: i64, + /// The level required to kick a user. + pub kick: i64, + /// The level required to redact an event. + pub redact: i64, + /// The default level required to send message events. + pub events_default: i64, + /// The default level required to send state events. + pub state_default: i64, + /// The default power level for every user in the room. + pub users_default: i64, + /// The level required to change the room's name. + pub room_name: i64, + /// The level required to change the room's avatar. + pub room_avatar: i64, + /// The level required to change the room's topic. + pub room_topic: i64, +} + +impl From for RoomPowerLevels { + fn from(value: RumaPowerLevels) -> Self { + fn state_event_level_for( + power_levels: &RumaPowerLevels, + event_type: &TimelineEventType, + ) -> i64 { + let default_state: i64 = power_levels.state_default.into(); + power_levels.events.get(event_type).map_or(default_state, |&level| level.into()) + } + Self { + ban: value.ban.into(), + invite: value.invite.into(), + kick: value.kick.into(), + redact: value.redact.into(), + events_default: value.events_default.into(), + state_default: value.state_default.into(), + users_default: value.users_default.into(), + room_name: state_event_level_for(&value, &TimelineEventType::RoomName), + room_avatar: state_event_level_for(&value, &TimelineEventType::RoomAvatar), + room_topic: state_event_level_for(&value, &TimelineEventType::RoomTopic), + } + } } #[uniffi::export(callback_interface)] diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 61e3e41a3..75e5c877a 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -1860,6 +1860,16 @@ impl Room { .power_levels()) } + /// Resets the room's power levels to the default values + /// + /// [spec]: https://spec.matrix.org/v1.9/client-server-api/#mroompower_levels + pub async fn reset_power_levels(&self) -> Result { + let default_power_levels = RoomPowerLevels::from(RoomPowerLevelsEventContent::new()); + let changes = RoomPowerLevelChanges::from(default_power_levels); + self.apply_power_level_changes(changes).await?; + self.room_power_levels().await + } + /// Gets the suggested role for the user with the provided `user_id`. /// /// This method checks the `RoomPowerLevels` events instead of loading the diff --git a/crates/matrix-sdk/tests/integration/room/joined.rs b/crates/matrix-sdk/tests/integration/room/joined.rs index f8988d352..442ee5b67 100644 --- a/crates/matrix-sdk/tests/integration/room/joined.rs +++ b/crates/matrix-sdk/tests/integration/room/joined.rs @@ -14,13 +14,13 @@ use matrix_sdk::{ }; use matrix_sdk_base::RoomState; use matrix_sdk_test::{ - async_test, test_json, EphemeralTestEvent, JoinedRoomBuilder, SyncResponseBuilder, - DEFAULT_TEST_ROOM_ID, + async_test, test_json, test_json::sync::CUSTOM_ROOM_POWER_LEVELS, EphemeralTestEvent, + JoinedRoomBuilder, SyncResponseBuilder, DEFAULT_TEST_ROOM_ID, }; use ruma::{ api::client::{membership::Invite3pidInit, receipt::create_receipt::v3::ReceiptType}, assign, event_id, - events::{receipt::ReceiptThread, room::message::RoomMessageEventContent}, + events::{receipt::ReceiptThread, room::message::RoomMessageEventContent, TimelineEventType}, int, mxc_uri, owned_event_id, room_id, thirdparty, uint, user_id, OwnedUserId, TransactionId, }; use serde_json::json; @@ -817,3 +817,38 @@ async fn get_users_with_power_levels_is_empty_if_power_level_info_is_not_availab assert!(room.users_with_power_levels().await.is_empty()); } + +#[async_test] +async fn reset_power_levels() { + let (client, server) = logged_in_client_with_server().await; + + mock_sync(&server, &*CUSTOM_ROOM_POWER_LEVELS, None).await; + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + let _response = client.sync_once(sync_settings).await.unwrap(); + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).unwrap(); + + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/state/m.room.power_levels/$")) + .and(header("authorization", "Bearer 1234")) + .and(body_partial_json(json!({ + "events": { + // 'm.room.avatar' is 100 here, if we receive a value '50', the reset worked + "m.room.avatar": 50, + "m.room.canonical_alias": 50, + "m.room.history_visibility": 100, + "m.room.name": 50, + "m.room.power_levels": 100, + "m.room.topic": 50 + }, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::EVENT_ID)) + .expect(1) + .mount(&server) + .await; + + let initial_power_levels = room.room_power_levels().await.unwrap(); + assert_eq!(initial_power_levels.events[&TimelineEventType::RoomAvatar], int!(100)); + + room.reset_power_levels().await.unwrap(); +} diff --git a/testing/matrix-sdk-test/src/test_json/sync.rs b/testing/matrix-sdk-test/src/test_json/sync.rs index 3a878d5ae..02a4ea12a 100644 --- a/testing/matrix-sdk-test/src/test_json/sync.rs +++ b/testing/matrix-sdk-test/src/test_json/sync.rs @@ -1714,3 +1714,144 @@ pub static SYNC_ADMIN_AND_MOD: Lazy = Lazy::new(|| { } }) }); + +pub static CUSTOM_ROOM_POWER_LEVELS: Lazy = Lazy::new(|| { + json!({ + "device_one_time_keys_count": {}, + "next_batch": "s526_47314_0_7_1_1_1_11444_1", + "device_lists": { + "changed": [ + "@admin:example.org" + ], + "left": [] + }, + "rooms": { + "invite": {}, + "join": { + *DEFAULT_TEST_ROOM_ID: { + "summary": { + "m.heroes": [ + "@example2:localhost" + ], + "m.joined_member_count": 1, + "m.invited_member_count": 0 + }, + "account_data": { + "events": [] + }, + "ephemeral": { + "events": [] + }, + "state": { + "events": [ + { + "content": { + "join_rule": "public" + }, + "event_id": "$15139375514WsgmR:localhost", + "origin_server_ts": 151393755000000_u64, + "sender": "@admin:localhost", + "state_key": "", + "type": "m.room.join_rules", + "unsigned": { + "age": 7034220 + } + }, + { + "content": { + "avatar_url": null, + "displayname": "admin", + "membership": "join" + }, + "event_id": "$151800140517rfvjc:localhost", + "membership": "join", + "origin_server_ts": 151800140000000_u64, + "sender": "@admin:localhost", + "state_key": "@admin:localhost", + "type": "m.room.member", + "unsigned": { + "age": 297036, + "replaces_state": "$151800111315tsynI:localhost" + } + }, + { + "content": { + "creator": "@example:localhost" + }, + "event_id": "$15139375510KUZHi:localhost", + "origin_server_ts": 151393755000000_u64, + "sender": "@admin:localhost", + "state_key": "", + "type": "m.room.create", + "unsigned": { + "age": 703422 + } + }, + { + "content": { + "ban": 100, + "events": { + "m.room.avatar": 100, + "m.room.canonical_alias": 50, + "m.room.history_visibility": 100, + "m.room.name": 50, + "m.room.power_levels": 100 + }, + "events_default": 0, + "invite": 0, + "kick": 50, + "redact": 50, + "state_default": 50, + "users": { + "@admin:localhost": 100 + }, + "users_default": 0 + }, + "event_id": "$15139375512JaHAW:localhost", + "origin_server_ts": 151393755000000_u64, + "sender": "@admin:localhost", + "state_key": "", + "type": "m.room.power_levels", + "unsigned": { + "age": 703422 + } + } + ] + }, + "timeline": { + "events": [ + { + "content": { + "body": "baba", + "format": "org.matrix.custom.html", + "formatted_body": "baba", + "msgtype": "m.text" + }, + "event_id": "$152037280074GZeOm:localhost", + "origin_server_ts": 152037280000000_u64, + "sender": "@admin:localhost", + "type": "m.room.message", + "unsigned": { + "age": 598971425 + } + } + ], + "limited": true, + "prev_batch": "t392-516_47314_0_7_1_1_1_11444_1" + }, + "unread_notifications": { + "highlight_count": 0, + "notification_count": 11 + } + } + }, + "leave": {} + }, + "to_device": { + "events": [] + }, + "presence": { + "events": [] + } + }) +}); From b7d6fd08f10623611a8bd1dba749dc7862c50db1 Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Thu, 29 Feb 2024 12:46:00 +0100 Subject: [PATCH 04/13] event cache: enforce unique access on the `EventCacheStore` --- crates/matrix-sdk/src/event_cache/mod.rs | 84 +++++++++++++++--------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index 621077708..187cd8188 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -128,8 +128,7 @@ impl EventCache { let inner = Arc::new(EventCacheInner { client: Arc::downgrade(client), by_room: Default::default(), - store, - process_lock: Default::default(), + store: Arc::new(Mutex::new(store)), drop_handles: Default::default(), }); @@ -181,9 +180,10 @@ impl EventCache { // Forget everything we know; we could have missed events, and we have // no way to reconcile at the moment! // TODO: implement Smart Matching™, + let store = inner.store.lock().await; let mut by_room = inner.by_room.write().await; for room_id in by_room.keys() { - if let Err(err) = inner.store.clear_room_events(room_id).await { + if let Err(err) = store.clear_room_events(room_id).await { error!("unable to clear room after room updates lag: {err}"); } } @@ -230,10 +230,12 @@ impl EventCache { // We could have received events during a previous sync; remove them all, since // we can't know where to insert the "initial events" with respect to // them. - self.inner.store.clear_room_events(room_id).await?; + let store = self.inner.store.lock().await; + + store.clear_room_events(room_id).await?; let _ = room_cache.inner.sender.send(RoomEventCacheUpdate::Clear); - room_cache.inner.append_events(events).await?; + room_cache.inner.append_events(&**store, events).await?; Ok(()) } @@ -248,14 +250,13 @@ struct EventCacheInner { by_room: RwLock>, /// Backend used for storage. - store: Arc, - - /// A lock to make sure that despite multiple updates coming to the - /// `EventCache`, it will only handle one at a time. /// /// [`Mutex`] is “fair”, as it is implemented as a FIFO. It is important to - /// ensure that multiple updates will be applied in the correct order. - process_lock: Mutex<()>, + /// ensure that multiple updates will be applied in the correct order, which + /// is enforced by taking the store lock when handling an update. + /// + /// TODO: replace with a cross-process lock + store: Arc>>, /// Handles to keep alive the task listening to updates. drop_handles: OnceLock>, @@ -271,7 +272,7 @@ impl EventCacheInner { async fn handle_room_updates(&self, updates: RoomUpdates) -> Result<()> { // First, take the lock that indicates we're processing updates, to avoid // handling multiple updates concurrently. - let _process_lock = self.process_lock.lock().await; + let store = self.store.lock().await; // Left rooms. for (room_id, left_room_update) in updates.leave { @@ -280,7 +281,7 @@ impl EventCacheInner { continue; }; - if let Err(err) = room.inner.handle_left_room_update(left_room_update).await { + if let Err(err) = room.inner.handle_left_room_update(&**store, left_room_update).await { // Non-fatal error, try to continue to the next room. error!("handling left room update: {err}"); } @@ -293,7 +294,9 @@ impl EventCacheInner { continue; }; - if let Err(err) = room.inner.handle_joined_room_update(joined_room_update).await { + if let Err(err) = + room.inner.handle_joined_room_update(&**store, joined_room_update).await + { // Non-fatal error, try to continue to the next room. error!("handling joined room update: {err}"); } @@ -358,7 +361,7 @@ impl Debug for RoomEventCache { impl RoomEventCache { /// Create a new [`RoomEventCache`] using the given room and store. - fn new(room: Room, store: Arc) -> Self { + fn new(room: Room, store: Arc>>) -> Self { Self { inner: Arc::new(RoomEventCacheInner::new(room, store)) } } @@ -369,10 +372,9 @@ impl RoomEventCache { pub async fn subscribe( &self, ) -> Result<(Vec, Receiver)> { - Ok(( - self.inner.store.room_events(self.inner.room.room_id()).await?, - self.inner.sender.subscribe(), - )) + let store = self.inner.store.lock().await; + + Ok((store.room_events(self.inner.room.room_id()).await?, self.inner.sender.subscribe())) } } @@ -381,8 +383,10 @@ struct RoomEventCacheInner { /// Sender part for subscribers to this room. sender: Sender, - /// A pointer to the store implementation used for this event cache. - store: Arc, + /// Backend used for storage, shared with the parent [`EventCacheInner`]. + /// + /// See comment there. + store: Arc>>, /// The Client [`Room`] this event cache pertains to. room: Room, @@ -391,13 +395,18 @@ struct RoomEventCacheInner { impl RoomEventCacheInner { /// Creates a new cache for a room, and subscribes to room updates, so as /// to handle new timeline events. - fn new(room: Room, store: Arc) -> Self { + fn new(room: Room, store: Arc>>) -> Self { let sender = Sender::new(32); Self { room, store, sender } } - async fn handle_joined_room_update(&self, updates: JoinedRoomUpdate) -> Result<()> { + async fn handle_joined_room_update( + &self, + store: &dyn EventCacheStore, + updates: JoinedRoomUpdate, + ) -> Result<()> { self.handle_timeline( + store, updates.timeline, updates.ephemeral.clone(), updates.account_data, @@ -409,6 +418,7 @@ impl RoomEventCacheInner { async fn handle_timeline( &self, + store: &dyn EventCacheStore, timeline: Timeline, ephemeral: Vec>, account_data: Vec>, @@ -419,7 +429,7 @@ impl RoomEventCacheInner { // timeline, but we're not there yet. In the meanwhile, clear the // items from the room. TODO: implement Smart Matching™. trace!("limited timeline, clearing all previous events"); - self.store.clear_room_events(self.room.room_id()).await?; + store.clear_room_events(self.room.room_id()).await?; let _ = self.sender.send(RoomEventCacheUpdate::Clear); } @@ -431,7 +441,7 @@ impl RoomEventCacheInner { || !ambiguity_changes.is_empty() { trace!("adding new events"); - self.store.add_room_events(self.room.room_id(), timeline.events.clone()).await?; + store.add_room_events(self.room.room_id(), timeline.events.clone()).await?; // Propagate events to observers. let _ = self.sender.send(RoomEventCacheUpdate::Append { @@ -446,20 +456,34 @@ impl RoomEventCacheInner { Ok(()) } - async fn handle_left_room_update(&self, updates: LeftRoomUpdate) -> Result<()> { - self.handle_timeline(updates.timeline, Vec::new(), Vec::new(), updates.ambiguity_changes) - .await?; + async fn handle_left_room_update( + &self, + store: &dyn EventCacheStore, + updates: LeftRoomUpdate, + ) -> Result<()> { + self.handle_timeline( + store, + updates.timeline, + Vec::new(), + Vec::new(), + updates.ambiguity_changes, + ) + .await?; Ok(()) } /// Append a set of events to the room cache and storage, notifying /// observers. - async fn append_events(&self, events: Vec) -> Result<()> { + async fn append_events( + &self, + store: &dyn EventCacheStore, + events: Vec, + ) -> Result<()> { if events.is_empty() { return Ok(()); } - self.store.add_room_events(self.room.room_id(), events.clone()).await?; + store.add_room_events(self.room.room_id(), events.clone()).await?; let _ = self.sender.send(RoomEventCacheUpdate::Append { events, From 724d133cce098468fa6687fdcf30e1dbba1bdb5d Mon Sep 17 00:00:00 2001 From: Hanadi Date: Fri, 8 Mar 2024 11:28:04 +0100 Subject: [PATCH 05/13] sdk&ffi: server unstable features support for MSC4028 (#3192) Fixes https://github.com/matrix-org/matrix-rust-sdk/issues/3191 Allows support for fetching the unstable_features from `/_matrix/clients/versions`. Specifically, to be used for checking the state of org.matrix.msc4028 through ffi to the clients. --- * sdk: fetch unstable_features supported by homeserver Signed-off-by: hanadi92 * ffi: add can_homeserver_push_encrypted_event_to_device method Signed-off-by: hanadi92 * fix: use copied instead of dereferencing Co-authored-by: Benjamin Bouvier Signed-off-by: Hanadi * fix: move can_homeserver_push_encrypted_event_to_device logic to sdk Signed-off-by: hanadi92 * fix: remove unused unstable features param in client builder Signed-off-by: hanadi92 * fix: use assert instead of asserteq for bool check Signed-off-by: hanadi92 * fix: documentation Signed-off-by: hanadi92 * Apply suggestions from code review Signed-off-by: Benjamin Bouvier --------- Signed-off-by: hanadi92 Signed-off-by: Hanadi Signed-off-by: Benjamin Bouvier Co-authored-by: Benjamin Bouvier --- .../src/notification_settings.rs | 7 ++ crates/matrix-sdk/src/client/builder.rs | 1 + crates/matrix-sdk/src/client/mod.rs | 102 ++++++++++++++++++ .../src/test_json/api_responses.rs | 3 +- 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/bindings/matrix-sdk-ffi/src/notification_settings.rs b/bindings/matrix-sdk-ffi/src/notification_settings.rs index a4cfa77c9..81cbf6b84 100644 --- a/bindings/matrix-sdk-ffi/src/notification_settings.rs +++ b/bindings/matrix-sdk-ffi/src/notification_settings.rs @@ -319,6 +319,13 @@ impl NotificationSettings { } } + /// Check whether [MSC 4028 push rule][rule] is enabled on the homeserver. + /// + /// [rule]: https://github.com/matrix-org/matrix-spec-proposals/blob/giomfo/push_encrypted_events/proposals/4028-push-all-encrypted-events-except-for-muted-rooms.md + pub async fn can_homeserver_push_encrypted_event_to_device(&self) -> bool { + self.sdk_client.can_homeserver_push_encrypted_event_to_device().await.unwrap() + } + /// Set whether user mentions are enabled. pub async fn set_user_mention_enabled( &self, diff --git a/crates/matrix-sdk/src/client/builder.rs b/crates/matrix-sdk/src/client/builder.rs index c000075ba..053d68b94 100644 --- a/crates/matrix-sdk/src/client/builder.rs +++ b/crates/matrix-sdk/src/client/builder.rs @@ -508,6 +508,7 @@ impl ClientBuilder { http_client, base_client, self.server_versions, + None, self.respect_login_well_known, event_cache, #[cfg(feature = "e2e-encryption")] diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 87c43fd1e..e372634ed 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -235,6 +235,9 @@ pub(crate) struct ClientInner { /// The Matrix versions the server supports (well-known ones only) server_versions: OnceCell>, + /// The unstable features and their on/off state on the server + unstable_features: OnceCell>, + /// Collection of locks individual client methods might want to use, either /// to ensure that only a single call to a method happens at once or to /// deduplicate multiple calls to a method. @@ -292,6 +295,7 @@ impl ClientInner { http_client: HttpClient, base_client: BaseClient, server_versions: Option>, + unstable_features: Option>, respect_login_well_known: bool, event_cache: OnceCell, #[cfg(feature = "e2e-encryption")] encryption_settings: EncryptionSettings, @@ -305,6 +309,7 @@ impl ClientInner { base_client, locks: Default::default(), server_versions: OnceCell::new_with(server_versions), + unstable_features: OnceCell::new_with(unstable_features), typing_notice_times: Default::default(), event_handlers: Default::default(), notification_handlers: Default::default(), @@ -1401,6 +1406,67 @@ impl Client { Ok(server_versions) } + /// Fetch unstable_features from homeserver + async fn request_unstable_features(&self) -> HttpResult> { + let unstable_features: BTreeMap = self + .inner + .http_client + .send( + get_supported_versions::Request::new(), + None, + self.homeserver().to_string(), + None, + &[MatrixVersion::V1_0], + Default::default(), + ) + .await? + .unstable_features; + + Ok(unstable_features) + } + + /// Get unstable features from `request_unstable_features` or cache + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, config::SyncSettings}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://localhost:8080")?; + /// # let mut client = Client::new(homeserver).await?; + /// let unstable_features = client.unstable_features().await?; + /// let msc_x = unstable_features.get("msc_x").unwrap_or(&false); + /// # anyhow::Ok(()) }; + /// ``` + pub async fn unstable_features(&self) -> HttpResult<&BTreeMap> { + let unstable_features = self + .inner + .unstable_features + .get_or_try_init(|| self.request_unstable_features()) + .await?; + + Ok(unstable_features) + } + + /// Check whether MSC 4028 is enabled on the homeserver. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, config::SyncSettings}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://localhost:8080")?; + /// # let mut client = Client::new(homeserver).await?; + /// let msc4028_enabled = + /// client.can_homeserver_push_encrypted_event_to_device().await?; + /// # anyhow::Ok(()) }; + /// ``` + pub async fn can_homeserver_push_encrypted_event_to_device(&self) -> HttpResult { + Ok(self.unstable_features().await?.get("org.matrix.msc4028").copied().unwrap_or(false)) + } + /// Get information of all our own devices. /// /// # Examples @@ -2006,6 +2072,7 @@ impl Client { self.inner.http_client.clone(), self.inner.base_client.clone_with_in_memory_state_store(), self.inner.server_versions.get().cloned(), + self.inner.unstable_features.get().cloned(), self.inner.respect_login_well_known, self.inner.event_cache.clone(), #[cfg(feature = "e2e-encryption")] @@ -2269,4 +2336,39 @@ pub(crate) mod tests { assert_eq!(result.avatar_url.clone().unwrap().to_string(), "mxc://example.me/someid"); assert!(!response.limited); } + + #[async_test] + async fn test_request_unstable_features() { + let server = MockServer::start().await; + let client = logged_in_client(Some(server.uri())).await; + + Mock::given(method("GET")) + .and(path("_matrix/client/versions")) + .respond_with( + ResponseTemplate::new(200).set_body_json(&*test_json::api_responses::VERSIONS), + ) + .mount(&server) + .await; + let unstable_features = client.request_unstable_features().await.unwrap(); + + assert_eq!(unstable_features.get("org.matrix.e2e_cross_signing"), Some(&true)); + assert_eq!(unstable_features, client.unstable_features().await.unwrap().clone()); + } + + #[async_test] + async fn test_can_homeserver_push_encrypted_event_to_device() { + let server = MockServer::start().await; + let client = logged_in_client(Some(server.uri())).await; + + Mock::given(method("GET")) + .and(path("_matrix/client/versions")) + .respond_with( + ResponseTemplate::new(200).set_body_json(&*test_json::api_responses::VERSIONS), + ) + .mount(&server) + .await; + + let msc4028_enabled = client.can_homeserver_push_encrypted_event_to_device().await.unwrap(); + assert!(msc4028_enabled); + } } diff --git a/testing/matrix-sdk-test/src/test_json/api_responses.rs b/testing/matrix-sdk-test/src/test_json/api_responses.rs index d53469771..8d052f209 100644 --- a/testing/matrix-sdk-test/src/test_json/api_responses.rs +++ b/testing/matrix-sdk-test/src/test_json/api_responses.rs @@ -324,7 +324,8 @@ pub static VERSIONS: Lazy = Lazy::new(|| { ], "unstable_features": { "org.matrix.label_based_filtering":true, - "org.matrix.e2e_cross_signing":true + "org.matrix.e2e_cross_signing":true, + "org.matrix.msc4028":true } }) }); From cb6b420ad0e2c49640fbec0bf99e00d1e24dd15a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 8 Mar 2024 16:03:02 +0100 Subject: [PATCH 06/13] ffi: add `previous` power levels to `OtherState::RoomPowerLevels` (#3199) This is needed to be able to diff between increases and decreases of power levels ("user Alice was promoted Admin", etc.). --- * ffi: add `previous` power levels to `OtherState::RoomPowerLevels` This is needed to be able to diff between increases and decreases of power levels. * ffi: please clippy * ffi: inline initialization of `previous` and `users` --- .../matrix-sdk-ffi/src/timeline/content.rs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index 51e71e6b9..de9302b7e 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/content.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/content.rs @@ -310,7 +310,7 @@ pub enum OtherState { RoomJoinRules, RoomName { name: Option }, RoomPinnedEvents, - RoomPowerLevels { users: HashMap }, + RoomPowerLevels { users: HashMap, previous: Option> }, RoomServerAcl, RoomThirdPartyInvite { display_name: Option }, RoomTombstone, @@ -353,18 +353,20 @@ impl From<&matrix_sdk_ui::timeline::AnyOtherFullStateEventContent> for OtherStat Self::RoomName { name } } Content::RoomPinnedEvents(_) => Self::RoomPinnedEvents, - Content::RoomPowerLevels(c) => { - let changes = match c { - FullContent::Original { content, prev_content } => { - power_level_user_changes(content, prev_content) - .iter() - .map(|(k, v)| (k.to_string(), *v)) - .collect() - } - FullContent::Redacted(_) => Default::default(), - }; - Self::RoomPowerLevels { users: changes } - } + Content::RoomPowerLevels(c) => match c { + FullContent::Original { content, prev_content } => Self::RoomPowerLevels { + users: power_level_user_changes(content, prev_content) + .iter() + .map(|(k, v)| (k.to_string(), *v)) + .collect(), + previous: prev_content.as_ref().map(|prev_content| { + prev_content.users.iter().map(|(k, &v)| (k.to_string(), v.into())).collect() + }), + }, + FullContent::Redacted(_) => { + Self::RoomPowerLevels { users: Default::default(), previous: None } + } + }, Content::RoomServerAcl(_) => Self::RoomServerAcl, Content::RoomThirdPartyInvite(c) => { let display_name = match c { From c41f7975b32ff83523e32b0cda8b61f50523eb01 Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Mon, 26 Feb 2024 19:29:18 +0100 Subject: [PATCH 07/13] labs: turn rrrepl into a timeline client --- .typos.toml | 1 + Cargo.lock | 230 ++++++- Cargo.toml | 1 + crates/matrix-sdk-ui/Cargo.toml | 2 +- crates/matrix-sdk/Cargo.toml | 2 +- labs/README.md | 4 +- labs/{rrrepl => multiverse}/Cargo.toml | 18 +- labs/multiverse/src/main.rs | 820 +++++++++++++++++++++++++ labs/rrrepl/src/main.rs | 212 ------- 9 files changed, 1050 insertions(+), 240 deletions(-) rename labs/{rrrepl => multiverse}/Cargo.toml (75%) create mode 100644 labs/multiverse/src/main.rs delete mode 100644 labs/rrrepl/src/main.rs diff --git a/.typos.toml b/.typos.toml index 425eff5d5..1165eecd4 100644 --- a/.typos.toml +++ b/.typos.toml @@ -22,6 +22,7 @@ sing = "sign" singed = "signed" singing = "signing" Nd = "Nd" +ratatui = "ratatui" [files] extend-exclude = [ diff --git a/Cargo.lock b/Cargo.lock index d30ccf281..b7f56f218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,12 +635,27 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -803,6 +818,33 @@ dependencies = [ "cc", ] +[[package]] +name = "color-eyre" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -815,6 +857,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.4.0" @@ -986,6 +1041,31 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -2693,6 +2773,15 @@ dependencies = [ "log", ] +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown 0.14.3", +] + [[package]] name = "mac" version = "0.1.1" @@ -3447,10 +3536,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] +[[package]] +name = "multiverse" +version = "0.1.0" +dependencies = [ + "anyhow", + "color-eyre", + "crossterm", + "futures-util", + "imbl", + "matrix-sdk", + "matrix-sdk-ui", + "ratatui", + "serde_json", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "url", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -3833,6 +3943,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "p256" version = "0.13.2" @@ -4488,6 +4604,26 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ratatui" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8" +dependencies = [ + "bitflags 2.4.2", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.8.1" @@ -4691,22 +4827,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rrrepl" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures-util", - "matrix-sdk", - "matrix-sdk-ui", - "serde_json", - "tokio", - "tracing", - "tracing-appender", - "tracing-subscriber", - "url", -] - [[package]] name = "rsa" version = "0.9.6" @@ -5299,6 +5419,36 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -5377,6 +5527,16 @@ dependencies = [ "der", ] +[[package]] +name = "stability" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -5459,6 +5619,28 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "strum" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.48", +] + [[package]] name = "subtle" version = "2.5.0" @@ -5926,6 +6108,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -6053,6 +6245,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unicode-width" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 9704b0730..e0a2f187d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ futures-core = "0.3.28" futures-executor = "0.3.21" futures-util = { version = "0.3.26", default-features = false, features = ["alloc"] } http = "0.2.6" +imbl = "2.0.0" itertools = "0.12.0" ruma = { git = "https://github.com/ruma/ruma", rev = "68c9bb0930f2195fa8672fbef9633ef62737df5d", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids", "unstable-msc3401"] } ruma-common = { git = "https://github.com/ruma/ruma", rev = "68c9bb0930f2195fa8672fbef9633ef62737df5d" } diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index f18b6edb5..7f8e40f88 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -31,7 +31,7 @@ eyeball-im-util = { workspace = true } futures-core = { workspace = true } futures-util = { workspace = true } fuzzy-matcher = "0.3.7" -imbl = { version = "2.0.0", features = ["serde"] } +imbl = { workspace = true, features = ["serde"] } indexmap = "2.0.0" itertools = { workspace = true } matrix-sdk = { workspace = true, features = ["experimental-oidc", "experimental-sliding-sync"] } diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index fa16af25b..85cec662c 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -82,7 +82,7 @@ futures-core = { workspace = true } futures-util = { workspace = true } http = { workspace = true } hyper = { version = "0.14.20", features = ["http1", "http2", "server"], optional = true } -imbl = { version = "2.0.0", features = ["serde"] } +imbl = { workspace = true, features = ["serde"] } indexmap = "2.0.2" js_int = "0.2.2" language-tags = { version = "0.3.2", optional = true } diff --git a/labs/README.md b/labs/README.md index 47e0a7047..d88110b99 100644 --- a/labs/README.md +++ b/labs/README.md @@ -13,8 +13,8 @@ Rust SDK can evolve, feel free to propose an experiment. ## Current experiments -- rrrepl: a *R*ead *R*eceipts REPL, to help with client-side computation of read-receipts. Useful - for debugging. +- multiverse: a TUI client mostly for quick development iteration of SDK features and debugging. + Run with `cargo run --bin multiverse matrix.org ~/.cache/multiverse-cache`. ## Archived experiments diff --git a/labs/rrrepl/Cargo.toml b/labs/multiverse/Cargo.toml similarity index 75% rename from labs/rrrepl/Cargo.toml rename to labs/multiverse/Cargo.toml index 1e6ee419f..91bfac2d3 100644 --- a/labs/rrrepl/Cargo.toml +++ b/labs/multiverse/Cargo.toml @@ -1,23 +1,25 @@ [package] -name = "rrrepl" +name = "multiverse" version = "0.1.0" edition = "2021" publish = false [[bin]] -name = "rrrepl" +name = "multiverse" test = false [dependencies] anyhow = "1" -tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] } -url = "2.2.2" -# when copy-pasting this, please use a git dependency or make sure that you -# have copied the example as it was at the time of the release you use. +color-eyre = "0.6.2" +crossterm = "0.27.0" +futures-util = { workspace = true } +imbl = { workspace = true } matrix-sdk = { path = "../../crates/matrix-sdk", features = ["sso-login"] } matrix-sdk-ui = { path = "../../crates/matrix-sdk-ui" } +ratatui = "0.26.1" +serde_json = { workspace = true } +tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] } tracing = { workspace = true } tracing-appender = { version = "0.2.2" } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } -futures-util = { workspace = true } -serde_json = { workspace = true } +url = "2.2.2" diff --git a/labs/multiverse/src/main.rs b/labs/multiverse/src/main.rs new file mode 100644 index 000000000..6751aab1b --- /dev/null +++ b/labs/multiverse/src/main.rs @@ -0,0 +1,820 @@ +use std::{ + collections::HashMap, + env, + io::{self, stdout, Write}, + path::PathBuf, + process::exit, + sync::{Arc, Mutex}, + time::Duration, +}; + +use color_eyre::config::HookBuilder; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use futures_util::{pin_mut, StreamExt as _}; +use imbl::Vector; +use matrix_sdk::{ + config::StoreConfig, + encryption::{BackupDownloadStrategy, EncryptionSettings}, + matrix_auth::MatrixSession, + ruma::{ + api::client::receipt::create_receipt::v3::ReceiptType, events::room::message::MessageType, + OwnedRoomId, RoomId, + }, + AuthSession, Client, RoomListEntry, ServerName, SqliteCryptoStore, SqliteStateStore, +}; +use matrix_sdk_ui::{ + room_list_service, + sync_service::{self, SyncService}, + timeline::{TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem}, +}; +use ratatui::{prelude::*, style::palette::tailwind, widgets::*}; +use tokio::{spawn, task::JoinHandle}; +use tracing::error; +use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter}; + +const HEADER_BG: Color = tailwind::BLUE.c950; +const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950; +const ALT_ROW_COLOR: Color = tailwind::SLATE.c900; +const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300; +const TEXT_COLOR: Color = tailwind::SLATE.c200; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(tracing_appender::rolling::hourly("/tmp/", "logs-")); + + tracing_subscriber::registry() + .with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or("".into()))) + .with(file_layer) + .init(); + + // Read the server name from the command line. + let Some(server_name) = env::args().nth(1) else { + eprintln!("Usage: {} ", env::args().next().unwrap()); + exit(1) + }; + + let config_path = env::args().nth(2).unwrap_or("/tmp/".to_owned()); + let client = configure_client(server_name, config_path).await?; + + init_error_hooks()?; + let terminal = init_terminal()?; + + let mut app = App::new(client).await?; + + app.run(terminal).await +} + +fn init_error_hooks() -> anyhow::Result<()> { + let (panic, error) = HookBuilder::default().into_hooks(); + let panic = panic.into_panic_hook(); + let error = error.into_eyre_hook(); + color_eyre::eyre::set_hook(Box::new(move |e| { + let _ = restore_terminal(); + error(e) + }))?; + std::panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + panic(info) + })); + Ok(()) +} + +fn init_terminal() -> anyhow::Result> { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn restore_terminal() -> anyhow::Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} + +#[derive(Default)] +struct StatefulList { + state: ListState, + items: Arc>>, +} + +#[derive(Default, PartialEq)] +enum DetailsMode { + #[default] + ReadReceipts, + TimelineItems, + // Events // TODO: Soon™ +} + +struct Timeline { + items: Arc>>>, + task: JoinHandle<()>, +} + +struct App { + /// Reference to the main SDK client. + client: Client, + + /// The sync service used for synchronizing events. + sync_service: Arc, + + /// Room list service rooms known to the app. + ui_rooms: Arc>>, + + /// Timelines data structures for each room. + timelines: Arc>>, + + /// Ratatui's list of room list entries. + room_list_entries: StatefulList, + + /// Task listening to room list service changes, and spawning timelines. + listen_task: JoinHandle<()>, + + /// Content of the latest status message, if set. + last_status_message: Arc>>, + + /// A task to automatically clear the status message in N seconds, if set. + clear_status_message: Option>, + + /// What's shown in the details view, aka the right panel. + details_mode: DetailsMode, + + /// The current room that's subscribed to in the room list's sliding sync. + current_room_subscription: Option, +} + +impl App { + async fn new(client: Client) -> anyhow::Result { + let sync_service = Arc::new(SyncService::builder(client.clone()).build().await?); + + let room_list_service = sync_service.room_list_service(); + + let all_rooms = room_list_service.all_rooms().await?; + let (rooms, stream) = all_rooms.entries(); + + let rooms = Arc::new(Mutex::new(rooms)); + let ui_rooms: Arc>> = + Default::default(); + let timelines = Arc::new(Mutex::new(HashMap::new())); + + let r = rooms.clone(); + let ur = ui_rooms.clone(); + let s = sync_service.clone(); + let t = timelines.clone(); + + let listen_task = spawn(async move { + pin_mut!(stream); + let rooms = r; + let ui_rooms = ur; + let sync_service = s; + let timelines = t; + + while let Some(diffs) = stream.next().await { + let all_rooms = { + // Apply the diffs to the list of room entries. + let mut rooms = rooms.lock().unwrap(); + for diff in diffs { + diff.apply(&mut rooms); + } + + // Collect rooms early to release the room entries list lock. + rooms + .iter() + .filter_map(|entry| entry.as_room_id().map(ToOwned::to_owned)) + .collect::>() + }; + + // Clone the previous set of ui rooms to avoid keeping the ui_rooms lock (which + // we couldn't do below, because it's a sync lock, and has to be + // sync b/o rendering; and we'd have to cross await points + // below). + let previous_ui_rooms = ui_rooms.lock().unwrap().clone(); + + let mut new_ui_rooms = HashMap::new(); + let mut new_timelines = Vec::new(); + + // Initialize all the new rooms. + for room_id in + all_rooms.into_iter().filter(|room_id| !previous_ui_rooms.contains_key(room_id)) + { + // Retrieve the room list service's Room. + let Ok(ui_room) = sync_service.room_list_service().room(&room_id).await else { + error!("error when retrieving room after an update"); + continue; + }; + + // Initialize the timeline. + let builder = match ui_room.default_room_timeline_builder().await { + Ok(builder) => builder, + Err(err) => { + error!("error when getting default timeline builder: {err}"); + continue; + } + }; + + if let Err(err) = ui_room.init_timeline_with_builder(builder).await { + error!("error when creating default timeline: {err}"); + } + + // Save the timeline in the cache. + let (items, stream) = ui_room.timeline().unwrap().subscribe().await; + let items = Arc::new(Mutex::new(items)); + + // Spawn a timeline task that will listen to all the timeline item changes. + let i = items.clone(); + let timeline_task = spawn(async move { + pin_mut!(stream); + let items = i; + while let Some(diff) = stream.next().await { + let mut items = items.lock().unwrap(); + diff.apply(&mut items); + } + }); + + new_timelines.push((room_id.clone(), Timeline { items, task: timeline_task })); + + // Save the room list service room in the cache. + new_ui_rooms.insert(room_id, ui_room); + } + + ui_rooms.lock().unwrap().extend(new_ui_rooms); + timelines.lock().unwrap().extend(new_timelines); + } + }); + + // This will sync (with encryption) until an error happens or the program is + // stopped. + sync_service.start().await; + + Ok(Self { + sync_service, + room_list_entries: StatefulList { state: Default::default(), items: rooms }, + client, + listen_task, + last_status_message: Default::default(), + clear_status_message: None, + ui_rooms, + details_mode: Default::default(), + timelines, + current_room_subscription: None, + }) + } +} + +impl App { + /// Set the current status message (displayed at the bottom), for a few + /// seconds. + fn set_status_message(&mut self, status: String) { + if let Some(handle) = self.clear_status_message.take() { + // Cancel the previous task to clear the status message. + handle.abort(); + } + + *self.last_status_message.lock().unwrap() = Some(status); + + let message = self.last_status_message.clone(); + self.clear_status_message = Some(spawn(async move { + // Clear the status message in 4 seconds. + tokio::time::sleep(Duration::from_secs(4)).await; + + *message.lock().unwrap() = None; + })); + } + + /// Mark the currently selected room as read. + async fn mark_as_read(&mut self) -> anyhow::Result<()> { + if let Some(room) = self + .room_list_entries + .state + .selected() + .and_then(|selected| { + self.room_list_entries.items.lock().unwrap().get(selected).cloned() + }) + .and_then(|entry| entry.as_room_id().map(ToOwned::to_owned)) + .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned()) + { + // Mark as read! + let did = room.timeline().unwrap().mark_as_read(ReceiptType::Read).await?; + + self.set_status_message(format!( + "did {}send a read receipt!", + if did { "" } else { "not " } + )); + } else { + self.set_status_message("missing room or nothing to show".to_owned()); + } + + Ok(()) + } + + fn subscribe_to_selected_room(&mut self, selected: usize) { + // Delete the subscription to the previous room, if any. + if let Some(room) = self.current_room_subscription.take() { + room.unsubscribe(); + } + + // Subscribe to the new room. + if let Some(room) = self + .room_list_entries + .items + .lock() + .unwrap() + .get(selected) + .cloned() + .and_then(|entry| entry.as_room_id().map(ToOwned::to_owned)) + .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned()) + { + room.subscribe(None); + self.current_room_subscription = Some(room); + } + } + + async fn render_loop(&mut self, mut terminal: Terminal) -> anyhow::Result<()> { + loop { + terminal.draw(|f| f.render_widget(&mut *self, f.size()))?; + + if crossterm::event::poll(Duration::from_millis(16))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + use KeyCode::*; + match key.code { + Char('q') | Esc => return Ok(()), + + Char('j') | Down => { + if let Some(i) = self.room_list_entries.next() { + self.subscribe_to_selected_room(i); + } + } + + Char('k') | Up => { + if let Some(i) = self.room_list_entries.previous() { + self.subscribe_to_selected_room(i); + } + } + + Char('s') => self.sync_service.start().await, + Char('S') => self.sync_service.stop().await?, + Char('r') => self.details_mode = DetailsMode::ReadReceipts, + Char('t') => self.details_mode = DetailsMode::TimelineItems, + + Char('b') if self.details_mode == DetailsMode::TimelineItems => {} + + Char('m') if self.details_mode == DetailsMode::ReadReceipts => { + self.mark_as_read().await? + } + + _ => {} + } + } + } + } + } + } + + async fn run(&mut self, terminal: Terminal) -> anyhow::Result<()> { + self.render_loop(terminal).await?; + + // At this point the user has exited the loop, so shut down the application. + restore_terminal()?; + + println!("Closing sync service..."); + + let s = self.sync_service.clone(); + let wait_for_termination = spawn(async move { + while let Some(state) = s.state().next().await { + if !matches!(state, sync_service::State::Running) { + break; + } + } + }); + + self.sync_service.stop().await?; + self.listen_task.abort(); + for timeline in self.timelines.lock().unwrap().values() { + timeline.task.abort(); + } + wait_for_termination.await.unwrap(); + + println!("okthxbye!"); + Ok(()) + } +} + +impl Widget for &mut App { + /// Render the whole app. + fn render(self, area: Rect, buf: &mut Buffer) { + // Create a space for header, todo list and the footer. + let vertical = + Layout::vertical([Constraint::Length(2), Constraint::Min(0), Constraint::Length(2)]); + let [header_area, rest_area, footer_area] = vertical.areas(area); + + // Create two chunks with equal horizontal screen space. One for the list and + // the other for the info block. + let horizontal = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]); + let [lhs, rhs] = horizontal.areas(rest_area); + + self.render_title(header_area, buf); + self.render_left(lhs, buf); + self.render_right(rhs, buf); + self.render_footer(footer_area, buf); + } +} + +impl App { + /// Render the top square (title of the program). + fn render_title(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new("Multiverse").bold().centered().render(area, buf); + } + + /// Renders the left part of the screen, that is, the list of rooms. + fn render_left(&mut self, area: Rect, buf: &mut Buffer) { + // We create two blocks, one is for the header (outer) and the other is for list + // (inner). + let outer_block = Block::default() + .borders(Borders::NONE) + .fg(TEXT_COLOR) + .bg(HEADER_BG) + .title("Room list") + .title_alignment(Alignment::Center); + let inner_block = + Block::default().borders(Borders::NONE).fg(TEXT_COLOR).bg(NORMAL_ROW_COLOR); + + // We get the inner area from outer_block. We'll use this area later to render + // the table. + let outer_area = area; + let inner_area = outer_block.inner(outer_area); + + // We can render the header in outer_area. + outer_block.render(outer_area, buf); + + // Iterate through all elements in the `items` and stylize them. + let items: Vec> = self + .room_list_entries + .items + .lock() + .unwrap() + .iter() + .enumerate() + .map(|(i, item)| { + let bg_color = match i % 2 { + 0 => NORMAL_ROW_COLOR, + _ => ALT_ROW_COLOR, + }; + + let line = if let Some(room) = + item.as_room_id().and_then(|room_id| self.client.get_room(room_id)) + { + format!("#{i} {}", room.room_id()) + } else { + "non-filled room".to_owned() + }; + + let line = Line::styled(line, TEXT_COLOR); + ListItem::new(line).bg(bg_color) + }) + .collect(); + + // Create a List from all list items and highlight the currently selected one. + let items = List::new(items) + .block(inner_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(SELECTED_STYLE_FG), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); + + StatefulWidget::render(items, inner_area, buf, &mut self.room_list_entries.state); + } + + /// Render the right part of the screen, showing the details of the current + /// view. + fn render_right(&mut self, area: Rect, buf: &mut Buffer) { + // Split the block into two parts: + // - outer_block with the title of the block. + // - inner_block that will contain the actual details. + let outer_block = Block::default() + .borders(Borders::NONE) + .fg(TEXT_COLOR) + .bg(HEADER_BG) + .title("Room view") + .title_alignment(Alignment::Center); + let inner_block = Block::default() + .borders(Borders::NONE) + .bg(NORMAL_ROW_COLOR) + .padding(Padding::horizontal(1)); + + // This is a similar process to what we did for list. outer_info_area will be + // used for header inner_info_area will be used for the list info. + let outer_area = area; + let inner_area = outer_block.inner(outer_area); + + // We can render the header. Inner area will be rendered later. + outer_block.render(outer_area, buf); + + // Helper to render some string as a paragraph. + let render_paragraph = |buf: &mut Buffer, content: String| { + Paragraph::new(content) + .block(inner_block.clone()) + .fg(TEXT_COLOR) + .wrap(Wrap { trim: false }) + .render(inner_area, buf); + }; + + if let Some(room_id) = self + .room_list_entries + .state + .selected() + .and_then(|i| self.room_list_entries.items.lock().unwrap().get(i).cloned()) + .and_then(|room_entry| room_entry.as_room_id().map(ToOwned::to_owned)) + { + match self.details_mode { + DetailsMode::ReadReceipts => { + // In read receipts mode, show the read receipts object as computed + // by the client. + match self.ui_rooms.lock().unwrap().get(&room_id).cloned() { + Some(room) => { + let receipts = room.read_receipts(); + render_paragraph( + buf, + format!( + r#"Read receipts: +- unread: {} +- notifications: {} +- mentions: {} + +--- + +{:?} +"#, + receipts.num_unread, + receipts.num_notifications, + receipts.num_mentions, + receipts + ), + ) + } + None => render_paragraph( + buf, + "(room disappeared in the room list service)".to_owned(), + ), + } + } + + DetailsMode::TimelineItems => { + if !self.render_timeline(&room_id, inner_block.clone(), inner_area, buf) { + render_paragraph(buf, "(room's timeline disappeared)".to_owned()) + } + } + } + } else { + render_paragraph(buf, "Nothing to see here...".to_owned()) + }; + } + + /// Renders the list of timeline items for the given room. + fn render_timeline( + &mut self, + room_id: &RoomId, + inner_block: Block<'_>, + inner_area: Rect, + buf: &mut Buffer, + ) -> bool { + let Some(items) = + self.timelines.lock().unwrap().get(room_id).map(|timeline| timeline.items.clone()) + else { + return false; + }; + + let items = items.lock().unwrap(); + let mut content = Vec::new(); + + for item in items.iter() { + match item.kind() { + TimelineItemKind::Event(ev) => { + let sender = ev.sender(); + + match ev.content() { + TimelineItemContent::Message(message) => { + if let MessageType::Text(text) = message.msgtype() { + content.push(format!("{}: {}", sender, text.body)) + } + } + + TimelineItemContent::RedactedMessage => { + content.push(format!("{}: -- redacted --", sender)) + } + TimelineItemContent::UnableToDecrypt(_) => { + content.push(format!("{}: (UTD)", sender)) + } + TimelineItemContent::Sticker(_) + | TimelineItemContent::MembershipChange(_) + | TimelineItemContent::ProfileChange(_) + | TimelineItemContent::OtherState(_) + | TimelineItemContent::FailedToParseMessageLike { .. } + | TimelineItemContent::FailedToParseState { .. } + | TimelineItemContent::Poll(_) + | TimelineItemContent::CallInvite => { + continue; + } + } + } + + TimelineItemKind::Virtual(virt) => match virt { + VirtualTimelineItem::DayDivider(unix_ts) => { + content.push(format!("Date: {unix_ts:?}")); + } + VirtualTimelineItem::ReadMarker => { + content.push("Read marker".to_owned()); + } + }, + } + } + + let list_items = content + .into_iter() + .enumerate() + .map(|(i, line)| { + let bg_color = match i % 2 { + 0 => NORMAL_ROW_COLOR, + _ => ALT_ROW_COLOR, + }; + let line = Line::styled(line, TEXT_COLOR); + ListItem::new(line).bg(bg_color) + }) + .collect::>(); + + let list = List::new(list_items) + .block(inner_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(SELECTED_STYLE_FG), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); + + let mut dummy_list_state = ListState::default(); + StatefulWidget::render(list, inner_area, buf, &mut dummy_list_state); + true + } + + /// Render the bottom part of the screen, with a status message if one is + /// set, or a default help message otherwise. + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + let content = if let Some(status_message) = self.last_status_message.lock().unwrap().clone() + { + status_message + } else { + match self.details_mode { + DetailsMode::ReadReceipts => { + "\nUse ↓↑ to move, s/S to start/stop the sync service, m to mark as read, t to show the timeline.".to_owned() + } + DetailsMode::TimelineItems => { + "\nUse ↓↑ to move, s/S to start/stop the sync service, r to show read receipts.".to_owned() + } + } + }; + Paragraph::new(content).centered().render(area, buf); + } +} + +impl StatefulList { + /// Focus the list on the next item, wraps around if needs be. + /// + /// Returns the index only if there was a meaningful change. + fn next(&mut self) -> Option { + let num_items = self.items.lock().unwrap().len(); + + // If there's no item to select, leave early. + if num_items == 0 { + self.state.select(None); + return None; + } + + // Otherwise, select the next one or wrap around. + let prev = self.state.selected(); + let new = prev.map_or(0, |i| if i >= num_items - 1 { 0 } else { i + 1 }); + + if prev != Some(new) { + self.state.select(Some(new)); + Some(new) + } else { + None + } + } + + /// Focus the list on the previous item, wraps around if needs be. + /// + /// Returns the index only if there was a meaningful change. + fn previous(&mut self) -> Option { + let num_items = self.items.lock().unwrap().len(); + + // If there's no item to select, leave early. + if num_items == 0 { + self.state.select(None); + return None; + } + + // Otherwise, select the previous one or wrap around. + let prev = self.state.selected(); + let new = prev.map_or(0, |i| if i == 0 { num_items - 1 } else { i - 1 }); + + if prev != Some(new) { + self.state.select(Some(new)); + Some(new) + } else { + None + } + } +} + +/// Configure the client so it's ready for sync'ing. +/// +/// Will log in or reuse a previous session. +async fn configure_client(server_name: String, config_path: String) -> anyhow::Result { + let server_name = ServerName::parse(&server_name)?; + + let config_path = PathBuf::from(config_path); + let client = Client::builder() + .store_config( + StoreConfig::default() + .crypto_store( + SqliteCryptoStore::open(config_path.join("crypto.sqlite"), None).await?, + ) + .state_store(SqliteStateStore::open(config_path.join("state.sqlite"), None).await?), + ) + .server_name(&server_name) + .with_encryption_settings(EncryptionSettings { + auto_enable_cross_signing: true, + backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure, + auto_enable_backups: true, + }) + .build() + .await?; + + // Try reading a session, otherwise create a new one. + let session_path = config_path.join("session.json"); + if let Ok(serialized) = std::fs::read_to_string(&session_path) { + let session: MatrixSession = serde_json::from_str(&serialized)?; + client.restore_session(session).await?; + println!("restored session"); + } else { + login_with_password(&client).await?; + println!("new login"); + + // Immediately save the session to disk. + if let Some(session) = client.session() { + let AuthSession::Matrix(session) = session else { panic!("unexpected oidc session") }; + let serialized = serde_json::to_string(&session)?; + std::fs::write(session_path, serialized)?; + println!("saved session"); + } + } + + Ok(client) +} + +/// Asks the user of a username and password, and try to login using the matrix +/// auth with those. +async fn login_with_password(client: &Client) -> anyhow::Result<()> { + println!("Logging in with username and password…"); + + loop { + print!("\nUsername: "); + stdout().flush().expect("Unable to write to stdout"); + let mut username = String::new(); + io::stdin().read_line(&mut username).expect("Unable to read user input"); + username = username.trim().to_owned(); + + print!("Password: "); + stdout().flush().expect("Unable to write to stdout"); + let mut password = String::new(); + io::stdin().read_line(&mut password).expect("Unable to read user input"); + password = password.trim().to_owned(); + + match client.matrix_auth().login_username(&username, &password).await { + Ok(_) => { + println!("Logged in as {username}"); + break; + } + Err(error) => { + println!("Error logging in: {error}"); + println!("Please try again\n"); + } + } + } + + Ok(()) +} diff --git a/labs/rrrepl/src/main.rs b/labs/rrrepl/src/main.rs deleted file mode 100644 index aa7706323..000000000 --- a/labs/rrrepl/src/main.rs +++ /dev/null @@ -1,212 +0,0 @@ -use std::{ - env, - io::{self, Write}, - process::exit, - sync::{Arc, Mutex}, -}; - -use futures_util::{pin_mut, StreamExt as _}; -use matrix_sdk::{ - config::StoreConfig, matrix_auth::MatrixSession, - ruma::api::client::receipt::create_receipt::v3::ReceiptType, AuthSession, Client, ServerName, - SqliteCryptoStore, SqliteStateStore, -}; -use matrix_sdk_ui::sync_service::{self, SyncService}; -use tokio::spawn; -use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let file_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_writer(tracing_appender::rolling::hourly("/tmp/", "logs-")); - - tracing_subscriber::registry() - .with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or("".into()))) - .with(file_layer) - .init(); - - let Some(server_name) = env::args().nth(1) else { - eprintln!("Usage: {} ", env::args().next().unwrap()); - exit(1) - }; - - login_and_sync(server_name).await?; - - Ok(()) -} - -/// Log in to the given homeserver and sync. -async fn login_and_sync(server_name: String) -> anyhow::Result<()> { - let server_name = ServerName::parse(&server_name)?; - - let client = Client::builder() - .store_config( - StoreConfig::default() - .crypto_store(SqliteCryptoStore::open("/tmp/crypto.sqlite", None).await?) - .state_store(SqliteStateStore::open("/tmp/state.sqlite", None).await?), - ) - .server_name(&server_name) - .build() - .await?; - - // Try reading from /tmp/session.json - if let Ok(serialized) = std::fs::read_to_string("/tmp/session.json") { - let session: MatrixSession = serde_json::from_str(&serialized)?; - client.restore_session(session).await?; - println!("restored session"); - } else { - login_with_password(&client).await?; - println!("new login"); - } - - let sync_service = SyncService::builder(client.clone()).build().await?; - - let room_list_service = sync_service.room_list_service(); - - let all_rooms = room_list_service.all_rooms().await?; - let (rooms, stream) = all_rooms.entries(); - - let rooms = Arc::new(Mutex::new(rooms.clone())); - - // This will sync (with encryption) until an error happens or the program is - // killed. - sync_service.start().await; - - let c = client.clone(); - let r = rooms.clone(); - let handle = spawn(async move { - pin_mut!(stream); - let rooms = r; - let client = c; - - while let Some(diffs) = stream.next().await { - let mut rooms = rooms.lock().unwrap(); - for diff in diffs { - diff.apply(&mut rooms); - } - println!("New update!"); - for (id, room) in rooms.iter().enumerate() { - if let Some(room) = room.as_room_id().and_then(|room_id| client.get_room(room_id)) { - println!("> #{id} {}: {:?}", room.room_id(), room.read_receipts()); - } - } - } - }); - - loop { - let mut command = String::new(); - - print!("$ "); - let _ = io::stdout().flush(); - io::stdin().read_line(&mut command).expect("Unable to read user input"); - - match command.trim() { - "rooms" => { - let rooms = rooms.lock().unwrap(); - for (id, room) in rooms.iter().enumerate() { - if let Some(room) = - room.as_room_id().and_then(|room_id| client.get_room(room_id)) - { - println!("> #{id} {}: {:?}", room.room_id(), room.read_receipts()); - } - } - } - - "start" => { - sync_service.start().await; - println!("> sync service started!"); - } - - "stop" => { - sync_service.stop().await?; - println!("> sync service stopped!"); - } - - "" | "exit" => { - break; - } - - _ => { - if let Some((_, id)) = command.split_once("send ") { - let id = id.trim().parse::()?; - let room_id = { rooms.lock().unwrap()[id].as_room_id().map(ToOwned::to_owned) }; - if let Some(room_id) = &room_id { - let room = room_list_service.room(room_id).await?; - - if !room.is_timeline_initialized() { - room.init_timeline_with_builder( - room.default_room_timeline_builder().await?, - ) - .await?; - } - let timeline = room.timeline().unwrap(); - - let did = timeline.mark_as_read(ReceiptType::Read).await?; - println!("> did {}send a read receipt!", if did { "" } else { "not " }); - } - } else { - println!("unknown command"); - } - } - } - } - - println!("Closing sync service..."); - - let sync_service = Arc::new(sync_service); - let s = sync_service.clone(); - let wait_for_termination = spawn(async move { - while let Some(state) = s.state().next().await { - if !matches!(state, sync_service::State::Running) { - break; - } - } - }); - - sync_service.stop().await?; - handle.abort(); - wait_for_termination.await.unwrap(); - - if let Some(session) = client.session() { - let AuthSession::Matrix(session) = session else { panic!("unexpected oidc session") }; - let serialized = serde_json::to_string(&session)?; - std::fs::write("/tmp/session.json", serialized)?; - println!("saved session"); - } - - println!("okthxbye!"); - - Ok(()) -} - -async fn login_with_password(client: &Client) -> anyhow::Result<()> { - println!("Logging in with username and password…"); - - loop { - print!("\nUsername: "); - io::stdout().flush().expect("Unable to write to stdout"); - let mut username = String::new(); - io::stdin().read_line(&mut username).expect("Unable to read user input"); - username = username.trim().to_owned(); - - print!("Password: "); - io::stdout().flush().expect("Unable to write to stdout"); - let mut password = String::new(); - io::stdin().read_line(&mut password).expect("Unable to read user input"); - password = password.trim().to_owned(); - - match client.matrix_auth().login_username(&username, &password).await { - Ok(_) => { - println!("Logged in as {username}"); - break; - } - Err(error) => { - println!("Error logging in: {error}"); - println!("Please try again\n"); - } - } - } - - Ok(()) -} From e57a02fd915f81ad84d57259ee3a8230fd2e446b Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Fri, 8 Mar 2024 16:28:06 +0100 Subject: [PATCH 08/13] multiverse: add support for backpagination --- labs/multiverse/src/main.rs | 121 +++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/labs/multiverse/src/main.rs b/labs/multiverse/src/main.rs index 6751aab1b..8bf18d58c 100644 --- a/labs/multiverse/src/main.rs +++ b/labs/multiverse/src/main.rs @@ -29,7 +29,10 @@ use matrix_sdk::{ use matrix_sdk_ui::{ room_list_service, sync_service::{self, SyncService}, - timeline::{TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem}, + timeline::{ + PaginationOptions, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem, + }, + Timeline as SdkTimeline, }; use ratatui::{prelude::*, style::palette::tailwind, widgets::*}; use tokio::{spawn, task::JoinHandle}; @@ -114,6 +117,7 @@ enum DetailsMode { } struct Timeline { + timeline: Arc, items: Arc>>>, task: JoinHandle<()>, } @@ -148,6 +152,8 @@ struct App { /// The current room that's subscribed to in the room list's sliding sync. current_room_subscription: Option, + + current_pagination: Arc>>>, } impl App { @@ -224,7 +230,8 @@ impl App { } // Save the timeline in the cache. - let (items, stream) = ui_room.timeline().unwrap().subscribe().await; + let sdk_timeline = ui_room.timeline().unwrap(); + let (items, stream) = sdk_timeline.subscribe().await; let items = Arc::new(Mutex::new(items)); // Spawn a timeline task that will listen to all the timeline item changes. @@ -238,7 +245,10 @@ impl App { } }); - new_timelines.push((room_id.clone(), Timeline { items, task: timeline_task })); + new_timelines.push(( + room_id.clone(), + Timeline { timeline: sdk_timeline, items, task: timeline_task }, + )); // Save the room list service room in the cache. new_ui_rooms.insert(room_id, ui_room); @@ -264,6 +274,7 @@ impl App { details_mode: Default::default(), timelines, current_room_subscription: None, + current_pagination: Default::default(), }) } } @@ -289,29 +300,73 @@ impl App { } /// Mark the currently selected room as read. - async fn mark_as_read(&mut self) -> anyhow::Result<()> { - if let Some(room) = self - .room_list_entries - .state - .selected() - .and_then(|selected| { - self.room_list_entries.items.lock().unwrap().get(selected).cloned() - }) - .and_then(|entry| entry.as_room_id().map(ToOwned::to_owned)) + async fn mark_as_read(&mut self) { + let Some(room) = self + .get_selected_room_id(None) .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned()) - { - // Mark as read! - let did = room.timeline().unwrap().mark_as_read(ReceiptType::Read).await?; - - self.set_status_message(format!( - "did {}send a read receipt!", - if did { "" } else { "not " } - )); - } else { + else { self.set_status_message("missing room or nothing to show".to_owned()); + return; + }; + + // Mark as read! + match room.timeline().unwrap().mark_as_read(ReceiptType::Read).await { + Ok(did) => { + self.set_status_message(format!( + "did {}send a read receipt!", + if did { "" } else { "not " } + )); + } + Err(err) => { + self.set_status_message(format!("error when marking a room as read: {err}",)); + } + } + } + + /// Run a small back-pagination (expect a batch of 20 events, continue until + /// we get 10 timeline items or hit the timeline start). + async fn back_paginate(&mut self) { + let Some(sdk_timeline) = self.get_selected_room_id(None).and_then(|room_id| { + self.timelines.lock().unwrap().get(&room_id).map(|timeline| timeline.timeline.clone()) + }) else { + self.set_status_message("missing timeline for room".to_owned()); + return; + }; + + let mut pagination = self.current_pagination.lock().unwrap(); + + // Cancel the previous back-pagination, if any. + if let Some(prev) = pagination.take() { + prev.abort(); } - Ok(()) + // Start a new one, request batches of 20 events, stop after 10 timeline items + // have been added. + *pagination = Some(spawn(async move { + if let Err(err) = + sdk_timeline.paginate_backwards(PaginationOptions::until_num_items(20, 10)).await + { + // TODO: would be nice to be able to set the status + // message remotely? + //self.set_status_message(format!( + //"Error during backpagination: {err}" + //)); + error!("Error during backpagination: {err}") + } + })); + } + + /// Returns the currently selected room id, if any. + fn get_selected_room_id(&self, selected: Option) -> Option { + let selected = selected.or_else(|| self.room_list_entries.state.selected())?; + + self.room_list_entries + .items + .lock() + .unwrap() + .get(selected) + .cloned() + .and_then(|entry| entry.as_room_id().map(ToOwned::to_owned)) } fn subscribe_to_selected_room(&mut self, selected: usize) { @@ -322,13 +377,7 @@ impl App { // Subscribe to the new room. if let Some(room) = self - .room_list_entries - .items - .lock() - .unwrap() - .get(selected) - .cloned() - .and_then(|entry| entry.as_room_id().map(ToOwned::to_owned)) + .get_selected_room_id(Some(selected)) .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned()) { room.subscribe(None); @@ -364,10 +413,12 @@ impl App { Char('r') => self.details_mode = DetailsMode::ReadReceipts, Char('t') => self.details_mode = DetailsMode::TimelineItems, - Char('b') if self.details_mode == DetailsMode::TimelineItems => {} + Char('b') if self.details_mode == DetailsMode::TimelineItems => { + self.back_paginate().await; + } Char('m') if self.details_mode == DetailsMode::ReadReceipts => { - self.mark_as_read().await? + self.mark_as_read().await } _ => {} @@ -531,13 +582,7 @@ impl App { .render(inner_area, buf); }; - if let Some(room_id) = self - .room_list_entries - .state - .selected() - .and_then(|i| self.room_list_entries.items.lock().unwrap().get(i).cloned()) - .and_then(|room_entry| room_entry.as_room_id().map(ToOwned::to_owned)) - { + if let Some(room_id) = self.get_selected_room_id(None) { match self.details_mode { DetailsMode::ReadReceipts => { // In read receipts mode, show the read receipts object as computed From 4ad79d6d44c68d1789e5741883f8ef580b14a827 Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Fri, 8 Mar 2024 17:44:55 +0100 Subject: [PATCH 09/13] multiverse: hide the password by using rpassword to prompt it --- Cargo.lock | 22 ++++++++++++++++++++++ labs/multiverse/Cargo.toml | 1 + labs/multiverse/src/main.rs | 8 ++------ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7f56f218..025df8ffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "matrix-sdk", "matrix-sdk-ui", "ratatui", + "rpassword", "serde_json", "tokio", "tracing", @@ -4827,6 +4828,17 @@ dependencies = [ "serde", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + [[package]] name = "rsa" version = "0.9.6" @@ -4847,6 +4859,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ruma" version = "0.9.4" diff --git a/labs/multiverse/Cargo.toml b/labs/multiverse/Cargo.toml index 91bfac2d3..308c563d5 100644 --- a/labs/multiverse/Cargo.toml +++ b/labs/multiverse/Cargo.toml @@ -17,6 +17,7 @@ imbl = { workspace = true } matrix-sdk = { path = "../../crates/matrix-sdk", features = ["sso-login"] } matrix-sdk-ui = { path = "../../crates/matrix-sdk-ui" } ratatui = "0.26.1" +rpassword = "7.3.1" serde_json = { workspace = true } tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] } tracing = { workspace = true } diff --git a/labs/multiverse/src/main.rs b/labs/multiverse/src/main.rs index 8bf18d58c..e6edcf34d 100644 --- a/labs/multiverse/src/main.rs +++ b/labs/multiverse/src/main.rs @@ -843,13 +843,9 @@ async fn login_with_password(client: &Client) -> anyhow::Result<()> { io::stdin().read_line(&mut username).expect("Unable to read user input"); username = username.trim().to_owned(); - print!("Password: "); - stdout().flush().expect("Unable to write to stdout"); - let mut password = String::new(); - io::stdin().read_line(&mut password).expect("Unable to read user input"); - password = password.trim().to_owned(); + let password = rpassword::prompt_password("Password.")?; - match client.matrix_auth().login_username(&username, &password).await { + match client.matrix_auth().login_username(&username, password.trim()).await { Ok(_) => { println!("Logged in as {username}"); break; From fd709b9d52ccca410dae3a7141ec6ca66708de46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= <76261501+zecakeh@users.noreply.github.com> Date: Sat, 9 Mar 2024 15:18:46 +0100 Subject: [PATCH 10/13] workspace: Bump ruma crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 4 ++-- crates/matrix-sdk/src/http_client/mod.rs | 7 +++++-- crates/matrix-sdk/src/matrix_auth/mod.rs | 8 +++++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d30ccf281..c6ff73a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4730,7 +4730,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.9.4" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "assign", "js_int", @@ -4746,7 +4746,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.17.4" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "as_variant", "assign", @@ -4765,7 +4765,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.12.1" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "as_variant", "base64 0.21.7", @@ -4795,7 +4795,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.27.11" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "as_variant", "indexmap 2.2.2", @@ -4819,7 +4819,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.8.0" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "js_int", "ruma-common", @@ -4831,7 +4831,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.1.0" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "as_variant", "html5ever", @@ -4843,7 +4843,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.9.3" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "js_int", "thiserror", @@ -4852,7 +4852,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.12.0" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "once_cell", "proc-macro-crate 2.0.2", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "ruma-push-gateway-api" version = "0.8.0" -source = "git+https://github.com/ruma/ruma?rev=68c9bb0930f2195fa8672fbef9633ef62737df5d#68c9bb0930f2195fa8672fbef9633ef62737df5d" +source = "git+https://github.com/ruma/ruma?rev=b2542df2bbbdf09af0612c9f28bcfa5620e1911c#b2542df2bbbdf09af0612c9f28bcfa5620e1911c" dependencies = [ "js_int", "ruma-common", diff --git a/Cargo.toml b/Cargo.toml index 9704b0730..86252e08b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,8 @@ futures-executor = "0.3.21" futures-util = { version = "0.3.26", default-features = false, features = ["alloc"] } http = "0.2.6" itertools = "0.12.0" -ruma = { git = "https://github.com/ruma/ruma", rev = "68c9bb0930f2195fa8672fbef9633ef62737df5d", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids", "unstable-msc3401"] } -ruma-common = { git = "https://github.com/ruma/ruma", rev = "68c9bb0930f2195fa8672fbef9633ef62737df5d" } +ruma = { git = "https://github.com/ruma/ruma", rev = "b2542df2bbbdf09af0612c9f28bcfa5620e1911c", features = ["client-api-c", "compat-upload-signatures", "compat-user-id", "compat-arbitrary-length-ids", "unstable-msc3401"] } +ruma-common = { git = "https://github.com/ruma/ruma", rev = "b2542df2bbbdf09af0612c9f28bcfa5620e1911c" } once_cell = "1.16.0" rand = "0.8.5" serde = "1.0.151" diff --git a/crates/matrix-sdk/src/http_client/mod.rs b/crates/matrix-sdk/src/http_client/mod.rs index 72390e427..409ae5319 100644 --- a/crates/matrix-sdk/src/http_client/mod.rs +++ b/crates/matrix-sdk/src/http_client/mod.rs @@ -136,8 +136,11 @@ impl HttpClient { span.record("config", debug(config)).record("request_id", request_id); let auth_scheme = R::METADATA.authentication; - if !matches!(auth_scheme, AuthScheme::AccessToken | AuthScheme::None) { - return Err(HttpError::NotClientRequest); + match auth_scheme { + AuthScheme::AccessToken | AuthScheme::AccessTokenOptional | AuthScheme::None => {} + AuthScheme::ServerSignatures => { + return Err(HttpError::NotClientRequest); + } } let request = diff --git a/crates/matrix-sdk/src/matrix_auth/mod.rs b/crates/matrix-sdk/src/matrix_auth/mod.rs index f9fafb6e2..e425d9b77 100644 --- a/crates/matrix-sdk/src/matrix_auth/mod.rs +++ b/crates/matrix-sdk/src/matrix_auth/mod.rs @@ -871,9 +871,11 @@ impl MatrixAuth { use ruma::api::client::uiaa::{AuthData, Password}; let auth_data = match login_info { - Some(login::v3::LoginInfo::Password(p)) => { - Some(AuthData::Password(Password::new(p.identifier, p.password))) - } + Some(login::v3::LoginInfo::Password(login::v3::Password { + identifier: Some(identifier), + password, + .. + })) => Some(AuthData::Password(Password::new(identifier, password))), // Other methods can't be immediately translated to an auth. _ => None, }; From e9cca7f68d021e5b5960e51118e0e7798b3f5704 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 11 Mar 2024 12:05:32 +0100 Subject: [PATCH 11/13] feat(ui,ffi): Add a new `experimental-room-list-with-unified-invites` feature. The idea of this patch is to explore the possibility to unify the `all_rooms` list with the `invites` list in `RoomListService`. Since this is entirely experimental, it's behind a new feature flag. The feature itself can be configured at runtime by using the new `SyncServiceBuilder::with_unified_invites_in_room_list` builder method, or directly with `RoomListService::new_with_unified_invites` constructor. --- bindings/matrix-sdk-ffi/Cargo.toml | 2 +- bindings/matrix-sdk-ffi/src/error.rs | 2 +- bindings/matrix-sdk-ffi/src/room.rs | 2 +- bindings/matrix-sdk-ffi/src/sync_service.rs | 9 ++++ crates/matrix-sdk-ui/Cargo.toml | 3 ++ .../src/room_list_service/mod.rs | 41 +++++++++++++++++-- .../src/room_list_service/state.rs | 2 + crates/matrix-sdk-ui/src/sync_service.rs | 27 +++++++++++- 8 files changed, 80 insertions(+), 8 deletions(-) diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index 4ca3307e2..502cf3407 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -29,7 +29,7 @@ eyeball-im = { workspace = true } extension-trait = "1.0.1" futures-core = { workspace = true } futures-util = { workspace = true } -matrix-sdk-ui = { workspace = true, features = ["e2e-encryption", "uniffi"] } +matrix-sdk-ui = { workspace = true, features = ["e2e-encryption", "uniffi", "experimental-room-list-with-unified-invites"] } mime = "0.3.16" once_cell = { workspace = true } opentelemetry = "0.21.0" diff --git a/bindings/matrix-sdk-ffi/src/error.rs b/bindings/matrix-sdk-ffi/src/error.rs index 80f7de2ef..27e7d9ed7 100644 --- a/bindings/matrix-sdk-ffi/src/error.rs +++ b/bindings/matrix-sdk-ffi/src/error.rs @@ -1,7 +1,7 @@ use std::fmt::Display; use matrix_sdk::{ - self, encryption::CryptoStoreError, event_cache::EventCacheError, oidc::OidcError, HttpError, + encryption::CryptoStoreError, event_cache::EventCacheError, oidc::OidcError, HttpError, IdParseError, NotificationSettingsError as SdkNotificationSettingsError, StoreError, }; use matrix_sdk_ui::{encryption_sync_service, notification_client, sync_service, timeline}; diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index 73ee3815a..375f0a2e0 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -1,4 +1,4 @@ -use std::{convert::TryFrom, sync::Arc}; +use std::sync::Arc; use anyhow::{Context, Result}; use matrix_sdk::{ diff --git a/bindings/matrix-sdk-ffi/src/sync_service.rs b/bindings/matrix-sdk-ffi/src/sync_service.rs index b1441ea6c..069966801 100644 --- a/bindings/matrix-sdk-ffi/src/sync_service.rs +++ b/bindings/matrix-sdk-ffi/src/sync_service.rs @@ -95,6 +95,15 @@ impl SyncServiceBuilder { #[uniffi::export(async_runtime = "tokio")] impl SyncServiceBuilder { + pub fn with_unified_invites_in_room_list( + self: Arc, + with_unified_invites: bool, + ) -> Arc { + let this = unwrap_or_clone_arc(self); + let builder = this.builder.with_unified_invites_in_room_list(with_unified_invites); + Arc::new(Self { builder }) + } + pub fn with_cross_process_lock(self: Arc, app_identifier: Option) -> Arc { let this = unwrap_or_clone_arc(self); let builder = this.builder.with_cross_process_lock(app_identifier); diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index f18b6edb5..1dd41ae72 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -12,6 +12,9 @@ default = ["e2e-encryption", "native-tls"] e2e-encryption = ["matrix-sdk/e2e-encryption"] +# This feature will unify the `invites` list with the `all_rooms` list. +experimental-room-list-with-unified-invites = [] + native-tls = ["matrix-sdk/native-tls"] rustls-tls = ["matrix-sdk/rustls-tls"] diff --git a/crates/matrix-sdk-ui/src/room_list_service/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/mod.rs index afd28bc2f..5d03cc650 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/mod.rs @@ -136,7 +136,24 @@ impl RoomListService { /// This won't start an encryption sync, and it's the user's responsibility /// to create one in this case using `EncryptionSync`. pub async fn new(client: Client) -> Result { - Self::new_internal(client, false).await + Self::new_internal( + client, + false, + #[cfg(feature = "experimental-room-list-with-unified-invites")] + false, + ) + .await + } + + /// Create a new `RoomList` that disables encryption, and enables the + /// unified invites (i.e. invites are part of the `all_rooms` list; side + /// note: the `invites` list is still present). + #[cfg(feature = "experimental-room-list-with-unified-invites")] + pub async fn new_with_unified_invites( + client: Client, + with_unified_invites: bool, + ) -> Result { + Self::new_internal(client, false, with_unified_invites).await } /// Create a new `RoomList` that enables encryption. @@ -144,10 +161,20 @@ impl RoomListService { /// This will include syncing the encryption information, so there must not /// be any instance of `EncryptionSync` running in the background. pub async fn new_with_encryption(client: Client) -> Result { - Self::new_internal(client, true).await + Self::new_internal( + client, + true, + #[cfg(feature = "experimental-room-list-with-unified-invites")] + false, + ) + .await } - async fn new_internal(client: Client, with_encryption: bool) -> Result { + async fn new_internal( + client: Client, + with_encryption: bool, + #[cfg(feature = "experimental-room-list-with-unified-invites")] with_unified_invites: bool, + ) -> Result { let mut builder = client .sliding_sync("room-list") .map_err(Error::SlidingSync)? @@ -185,6 +212,8 @@ impl RoomListService { (StateEventType::RoomMember, "$ME".to_owned()), (StateEventType::RoomPowerLevels, "".to_owned()), ]), + #[cfg(feature = "experimental-room-list-with-unified-invites")] + with_unified_invites, )) .await .map_err(Error::SlidingSync)? @@ -479,11 +508,15 @@ impl RoomListService { /// properties, so that they are exactly the same. fn configure_all_or_visible_rooms_list( list_builder: SlidingSyncListBuilder, + #[cfg(feature = "experimental-room-list-with-unified-invites")] with_invites: bool, ) -> SlidingSyncListBuilder { + #[cfg(not(feature = "experimental-room-list-with-unified-invites"))] + let with_invites = false; + list_builder .sort(vec!["by_recency".to_owned(), "by_name".to_owned()]) .filters(Some(assign!(SyncRequestListFilters::default(), { - is_invite: Some(false), + is_invite: Some(with_invites), is_tombstoned: Some(false), not_room_types: vec!["m.space".to_owned()], }))) diff --git a/crates/matrix-sdk-ui/src/room_list_service/state.rs b/crates/matrix-sdk-ui/src/room_list_service/state.rs index 04faf8798..f2c1991d7 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/state.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/state.rs @@ -120,6 +120,8 @@ impl Action for AddVisibleRooms { (StateEventType::RoomEncryption, "".to_owned()), (StateEventType::RoomMember, "$LAZY".to_owned()), ]), + #[cfg(feature = "experimental-room-list-with-unified-invites")] + false, )) .await .map_err(Error::SlidingSync)?; diff --git a/crates/matrix-sdk-ui/src/sync_service.rs b/crates/matrix-sdk-ui/src/sync_service.rs index d86343a4c..25b36baa6 100644 --- a/crates/matrix-sdk-ui/src/sync_service.rs +++ b/crates/matrix-sdk-ui/src/sync_service.rs @@ -435,6 +435,10 @@ pub struct SyncServiceBuilder { /// SDK client. client: Client, + /// Whether we want to unify `all_rooms` and `invites`. + #[cfg(feature = "experimental-room-list-with-unified-invites")] + with_unified_invites_in_room_list: bool, + /// Is the cross-process lock for the crypto store enabled? with_cross_process_lock: bool, @@ -445,7 +449,20 @@ pub struct SyncServiceBuilder { impl SyncServiceBuilder { fn new(client: Client) -> Self { - Self { client, with_cross_process_lock: false, identifier: "app".to_owned() } + Self { + client, + #[cfg(feature = "experimental-room-list-with-unified-invites")] + with_unified_invites_in_room_list: false, + with_cross_process_lock: false, + identifier: "app".to_owned(), + } + } + + #[cfg(feature = "experimental-room-list-with-unified-invites")] + pub fn with_unified_invites_in_room_list(mut self, with_unified_invites: bool) -> Self { + self.with_unified_invites_in_room_list = with_unified_invites; + + self } /// Enables the cross-process lock, if the sync service is being built in a @@ -475,8 +492,16 @@ impl SyncServiceBuilder { pub async fn build(self) -> Result { let encryption_sync_permit = Arc::new(AsyncMutex::new(EncryptionSyncPermit::new())); + #[cfg(not(feature = "experimental-room-list-with-unified-invites"))] let room_list = RoomListService::new(self.client.clone()).await?; + #[cfg(feature = "experimental-room-list-with-unified-invites")] + let room_list = RoomListService::new_with_unified_invites( + self.client.clone(), + self.with_unified_invites_in_room_list, + ) + .await?; + let encryption_sync = Arc::new( EncryptionSyncService::new( self.identifier, From 899e4db8d08ec4b20cdd945b83b350cf83935c5e Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 8 Mar 2024 10:36:29 +0000 Subject: [PATCH 12/13] crypto: Break up the expiration tests for clarity --- .../src/olm/group_sessions/outbound.rs | 219 ++++++++++++------ 1 file changed, 150 insertions(+), 69 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs index b75fd0820..25d4021b2 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs @@ -735,20 +735,16 @@ pub struct PickledOutboundGroupSession { #[cfg(test)] mod tests { - use std::{sync::atomic::Ordering, time::Duration}; + use std::time::Duration; - use matrix_sdk_test::async_test; use ruma::{ - device_id, events::room::{ encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility, - message::RoomMessageEventContent, }, - room_id, uint, user_id, EventEncryptionAlgorithm, + uint, EventEncryptionAlgorithm, }; use super::{EncryptionSettings, ROTATION_MESSAGES, ROTATION_PERIOD}; - use crate::{Account, MegolmError}; #[test] fn test_encryption_settings_conversion() { @@ -768,78 +764,163 @@ mod tests { assert_eq!(settings.rotation_period_msgs, 500); } - #[async_test] #[cfg(any(target_os = "linux", target_os = "macos", target_arch = "wasm32"))] - async fn test_expiration() -> Result<(), MegolmError> { - use ruma::{serde::Raw, SecondsSinceUnixEpoch}; + mod expiration { + use std::{sync::atomic::Ordering, time::Duration}; - let settings = EncryptionSettings { rotation_period_msgs: 1, ..Default::default() }; - - let account = - Account::with_device_id(user_id!("@alice:example.org"), device_id!("DEVICEID")) - .static_data; - let (session, _) = account - .create_group_session_pair(room_id!("!test_room:example.org"), settings) - .await - .unwrap(); - - assert!(!session.expired()); - let _ = session - .encrypt( - "m.room.message", - &Raw::new(&RoomMessageEventContent::text_plain("Test message"))?.cast(), - ) - .await; - assert!(session.expired()); - - let settings = EncryptionSettings { - rotation_period: Duration::from_millis(100), - ..Default::default() + use matrix_sdk_test::async_test; + use ruma::{ + device_id, events::room::message::RoomMessageEventContent, room_id, serde::Raw, uint, + user_id, SecondsSinceUnixEpoch, }; - let (mut session, _) = account - .create_group_session_pair(room_id!("!test_room:example.org"), settings) - .await - .unwrap(); + use crate::{olm::OutboundGroupSession, Account, EncryptionSettings, MegolmError}; - assert!(!session.expired()); - - let now = SecondsSinceUnixEpoch::now(); - session.creation_time = SecondsSinceUnixEpoch(now.get() - uint!(3600)); - assert!(session.expired()); - - let settings = EncryptionSettings { rotation_period_msgs: 0, ..Default::default() }; - - let (session, _) = account - .create_group_session_pair(room_id!("!test_room:example.org"), settings) - .await - .unwrap(); - - assert!(!session.expired()); - - let _ = session - .encrypt( - "m.room.message", - &Raw::new(&RoomMessageEventContent::text_plain("Test message"))?.cast(), - ) + #[async_test] + async fn session_is_not_expired_if_no_messages_sent_and_no_time_passed() { + // Given a session that expires after one message + let session = create_session(EncryptionSettings { + rotation_period_msgs: 1, + ..Default::default() + }) .await; - assert!(session.expired()); - let settings = EncryptionSettings { rotation_period_msgs: 100_000, ..Default::default() }; + // When we send no messages at all - let (session, _) = account - .create_group_session_pair(room_id!("!test_room:example.org"), settings) - .await - .unwrap(); + // Then it is not expired + assert!(!session.expired()); + } - assert!(!session.expired()); - session.message_count.store(1000, Ordering::SeqCst); - assert!(!session.expired()); - session.message_count.store(9999, Ordering::SeqCst); - assert!(!session.expired()); - session.message_count.store(10_000, Ordering::SeqCst); - assert!(session.expired()); + #[async_test] + async fn session_is_expired_if_we_rotate_every_message_and_one_was_sent( + ) -> Result<(), MegolmError> { + // Given a session that expires after one message + let session = create_session(EncryptionSettings { + rotation_period_msgs: 1, + ..Default::default() + }) + .await; - Ok(()) + // When we send a message + let _ = session + .encrypt( + "m.room.message", + &Raw::new(&RoomMessageEventContent::text_plain("Test message"))?.cast(), + ) + .await; + + // Then the session is expired + assert!(session.expired()); + + Ok(()) + } + + #[async_test] + async fn session_with_short_rotation_period_is_not_expired_after_no_time() { + // Given a session with a 100ms expiration + let session = create_session(EncryptionSettings { + rotation_period: Duration::from_millis(100), + ..Default::default() + }) + .await; + + // When we don't allow any time to pass + + // Then it is not expired + assert!(!session.expired()); + } + + #[async_test] + async fn session_is_expired_after_rotation_period() { + // Given a session with a 100ms expiration + let mut session = create_session(EncryptionSettings { + rotation_period: Duration::from_millis(100), + ..Default::default() + }) + .await; + + // When one hour has passed + let now = SecondsSinceUnixEpoch::now(); + session.creation_time = SecondsSinceUnixEpoch(now.get() - uint!(3600)); + + // Then the session is expired + assert!(session.expired()); + } + + #[async_test] + async fn session_with_zero_msgs_rotation_is_not_expired_initially() { + // Given a session that is supposed to expire after zero messages + let session = create_session(EncryptionSettings { + rotation_period_msgs: 0, + ..Default::default() + }) + .await; + + // When we send no messages + + // Then the session is not expired: we are protected against this nonsensical + // setup + assert!(!session.expired()); + } + + #[async_test] + async fn session_with_zero_msgs_rotation_expires_after_one_message( + ) -> Result<(), MegolmError> { + // Given a session that is supposed to expire after zero messages + let session = create_session(EncryptionSettings { + rotation_period_msgs: 0, + ..Default::default() + }) + .await; + + // When we send a message + let _ = session + .encrypt( + "m.room.message", + &Raw::new(&RoomMessageEventContent::text_plain("Test message"))?.cast(), + ) + .await; + + // Then the session is expired: we treated rotation_period_msgs=0 as if it were + // =1 + assert!(session.expired()); + + Ok(()) + } + + #[async_test] + async fn session_expires_after_10k_messages_even_if_we_ask_for_more() { + // Given we asked to expire after 100K messages + let session = create_session(EncryptionSettings { + rotation_period_msgs: 100_000, + ..Default::default() + }) + .await; + + // Sanity: it does not expire after <10K messages + assert!(!session.expired()); + session.message_count.store(1000, Ordering::SeqCst); + assert!(!session.expired()); + session.message_count.store(9999, Ordering::SeqCst); + assert!(!session.expired()); + + // When we have sent >= 10K messages + session.message_count.store(10_000, Ordering::SeqCst); + + // Then it is considered expired: we enforce a maximum of 10K messages before + // rotation. + assert!(session.expired()); + } + + async fn create_session(settings: EncryptionSettings) -> OutboundGroupSession { + let account = + Account::with_device_id(user_id!("@alice:example.org"), device_id!("DEVICEID")) + .static_data; + let (session, _) = account + .create_group_session_pair(room_id!("!test_room:example.org"), settings) + .await + .unwrap(); + session + } } } From 1ea163271bcca2f0d9fa6a8767c751c887e62fae Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:16:39 +0000 Subject: [PATCH 13/13] crypto: Include event timestamp in decryption failure logs Co-authored-by: Benjamin Bouvier Signed-off-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- Cargo.lock | 1 + crates/matrix-sdk-crypto/CHANGELOG.md | 3 ++ crates/matrix-sdk-crypto/Cargo.toml | 1 + crates/matrix-sdk-crypto/src/machine.rs | 8 ++- crates/matrix-sdk-crypto/src/utilities.rs | 62 +++++++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c6ff73a65..579e899b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3121,6 +3121,7 @@ dependencies = [ "stream_assert", "subtle", "thiserror", + "time", "tokio", "tokio-stream", "tracing", diff --git a/crates/matrix-sdk-crypto/CHANGELOG.md b/crates/matrix-sdk-crypto/CHANGELOG.md index 42d99e867..d5392f44a 100644 --- a/crates/matrix-sdk-crypto/CHANGELOG.md +++ b/crates/matrix-sdk-crypto/CHANGELOG.md @@ -29,6 +29,9 @@ Additions: - Add new API `store::Store::export_room_keys_stream` that provides room keys on demand. +- Include event timestamps on logs from event decryption. + ([#3194](https://github.com/matrix-org/matrix-rust-sdk/pull/3194)) + # 0.7.0 - Add method to mark a list of inbound group sessions as backed up: diff --git a/crates/matrix-sdk-crypto/Cargo.toml b/crates/matrix-sdk-crypto/Cargo.toml index b579e64d8..158e8a35d 100644 --- a/crates/matrix-sdk-crypto/Cargo.toml +++ b/crates/matrix-sdk-crypto/Cargo.toml @@ -52,6 +52,7 @@ serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } sha2 = { workspace = true } subtle = "2.5.0" +time = { version = "0.3.34", features = ["formatting"] } tokio-stream = { workspace = true, features = ["sync"] } tokio = { workspace = true } thiserror = { workspace = true } diff --git a/crates/matrix-sdk-crypto/src/machine.rs b/crates/matrix-sdk-crypto/src/machine.rs index 8274bbb26..0f558b95d 100644 --- a/crates/matrix-sdk-crypto/src/machine.rs +++ b/crates/matrix-sdk-crypto/src/machine.rs @@ -88,6 +88,7 @@ use crate::{ }, EventEncryptionAlgorithm, Signatures, }, + utilities::timestamp_to_iso8601, verification::{Verification, VerificationMachine, VerificationRequest}, CrossSigningKeyExport, CryptoStoreError, KeysQueryRequest, LocalTrust, ReadOnlyDevice, RoomKeyImportResult, SignatureError, ToDeviceRequest, @@ -1534,7 +1535,7 @@ impl OlmMachine { /// * `event` - The event that should be decrypted. /// /// * `room_id` - The ID of the room where the event was sent to. - #[instrument(skip_all, fields(?room_id, event_id, sender, algorithm, session_id, sender_key))] + #[instrument(skip_all, fields(?room_id, event_id, origin_server_ts, sender, algorithm, session_id, sender_key))] pub async fn decrypt_room_event( &self, event: &Raw, @@ -1545,6 +1546,11 @@ impl OlmMachine { tracing::Span::current() .record("sender", debug(&event.sender)) .record("event_id", debug(&event.event_id)) + .record( + "origin_server_ts", + timestamp_to_iso8601(event.origin_server_ts) + .unwrap_or_else(|| "".to_owned()), + ) .record("algorithm", debug(event.content.algorithm())); let content: SupportedEventEncryptionSchemes<'_> = match &event.content.scheme { diff --git a/crates/matrix-sdk-crypto/src/utilities.rs b/crates/matrix-sdk-crypto/src/utilities.rs index 09d12f0f2..103b39aa7 100644 --- a/crates/matrix-sdk-crypto/src/utilities.rs +++ b/crates/matrix-sdk-crypto/src/utilities.rs @@ -12,6 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::num::NonZeroU8; + +use ruma::MilliSecondsSinceUnixEpoch; +use time::{ + format_description::well_known::{iso8601, Iso8601}, + OffsetDateTime, +}; + #[cfg(test)] pub(crate) fn json_convert(value: &T) -> serde_json::Result where @@ -21,3 +29,57 @@ where let json = serde_json::to_string(value)?; serde_json::from_str(&json) } + +const ISO8601_WITH_MILLIS: iso8601::EncodedConfig = iso8601::Config::DEFAULT + .set_time_precision(iso8601::TimePrecision::Second { decimal_digits: NonZeroU8::new(3) }) + .encode(); + +/// Format the given timestamp into a human-readable timestamp. +/// +/// # Returns +/// +/// Provided the timestamp fits within an `OffsetDateTime` (ie, it is on or +/// before year 9999), a string that looks like `1970-01-01T00:00:00.000Z`. +/// Otherwise, `None`. +pub fn timestamp_to_iso8601(ts: MilliSecondsSinceUnixEpoch) -> Option { + let nanos_since_epoch = i128::from(ts.get()) * 1_000_000; + + // OffsetDateTime has a max year of 9999, whereas MilliSecondsSinceUnixEpoch has + // a max year of 285427, so `from_unix_timestamp_nanos` can overflow for very + // large timestamps. (The Y10K problem!) + let dt = OffsetDateTime::from_unix_timestamp_nanos(nanos_since_epoch).ok()?; + + // SAFETY: `format` can fail if: + // * The input lacks information on a component we have asked it to format + // (eg, it is given a `Time` and we ask it for a date), or + // * The input contains an invalid component (eg 30th February), or + // * An `io::Error` is raised internally. + // + // The first two cannot occur because we know we are giving it a valid + // OffsetDateTime that has all the components we are asking it to print. + // + // The third should not occur because we are formatting a short string to an + // in-memory buffer. + + Some(dt.format(&Iso8601::).unwrap()) +} + +#[cfg(test)] +pub(crate) mod tests { + use ruma::{MilliSecondsSinceUnixEpoch, UInt}; + + use super::timestamp_to_iso8601; + + #[test] + fn test_timestamp_to_iso8601() { + assert_eq!( + timestamp_to_iso8601(MilliSecondsSinceUnixEpoch(UInt::new_saturating(0))), + Some("1970-01-01T00:00:00.000Z".to_owned()) + ); + assert_eq!( + timestamp_to_iso8601(MilliSecondsSinceUnixEpoch(UInt::new_saturating(1709657033012))), + Some("2024-03-05T16:43:53.012Z".to_owned()) + ); + assert_eq!(timestamp_to_iso8601(MilliSecondsSinceUnixEpoch(UInt::MAX)), None); + } +}