diff --git a/crates/matrix-sdk-base/CHANGELOG.md b/crates/matrix-sdk-base/CHANGELOG.md index 2cad8b2b12e..35e3964e78a 100644 --- a/crates/matrix-sdk-base/CHANGELOG.md +++ b/crates/matrix-sdk-base/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate +### Features +- [**breaking**] The `RoomInfo` method now remembers the inviter at the time + when the `BaseClient::room_joined()` method was called. The caller is + responsible to remember the inviter before a server request to join the room + is made. The `RoomInfo::invite_accepted_at` method was removed, the + `RoomInfo::invite_details` method returns both the timestamp and the + inviter. + ([#5390](https://github.com/matrix-org/matrix-rust-sdk/pull/5390)) + ## [0.13.0] - 2025-07-10 ### Features diff --git a/crates/matrix-sdk-base/src/client.rs b/crates/matrix-sdk-base/src/client.rs index 0e7b0544bc5..84b2482650d 100644 --- a/crates/matrix-sdk-base/src/client.rs +++ b/crates/matrix-sdk-base/src/client.rs @@ -43,7 +43,7 @@ use ruma::{ }, push::Ruleset, time::Instant, - OwnedRoomId, OwnedUserId, RoomId, UserId, + MilliSecondsSinceUnixEpoch, OwnedRoomId, OwnedUserId, RoomId, UserId, }; use tokio::sync::{broadcast, Mutex}; #[cfg(feature = "e2e-encryption")] @@ -66,7 +66,7 @@ use crate::{ StateStoreDataValue, StateStoreExt, StoreConfig, }, sync::{RoomUpdates, SyncResponse}, - RoomStateFilter, SessionMeta, + InviteAcceptanceDetails, RoomStateFilter, SessionMeta, }; /// A no (network) IO client implementation. @@ -432,21 +432,36 @@ impl BaseClient { /// /// Update the internal and cached state accordingly. Return the final Room. /// + /// # Arguments + /// + /// * `room_id` - The unique ID identifying the joined room. + /// * `inviter` - When joining this room in response to an invitation, the + /// inviter should be recorded before sending the join request to the + /// server. Providing the inviter here ensures that the + /// [`InviteAcceptanceDetails`] are stored for this room. + /// /// # Examples /// /// ```rust /// # use matrix_sdk_base::{BaseClient, store::StoreConfig, RoomState, ThreadingSupport}; - /// # use ruma::OwnedRoomId; + /// # use ruma::{OwnedRoomId, OwnedUserId, RoomId}; /// # async { /// # let client = BaseClient::new(StoreConfig::new("example".to_owned()), ThreadingSupport::Disabled); /// # async fn send_join_request() -> anyhow::Result { todo!() } + /// # async fn maybe_get_inviter(room_id: &RoomId) -> anyhow::Result> { todo!() } + /// # let room_id: &RoomId = todo!(); + /// let maybe_inviter = maybe_get_inviter(room_id).await?; /// let room_id = send_join_request().await?; - /// let room = client.room_joined(&room_id).await?; + /// let room = client.room_joined(&room_id, maybe_inviter).await?; /// /// assert_eq!(room.state(), RoomState::Joined); /// # anyhow::Ok(()) }; /// ``` - pub async fn room_joined(&self, room_id: &RoomId) -> Result { + pub async fn room_joined( + &self, + room_id: &RoomId, + inviter: Option, + ) -> Result { let room = self.state_store.get_or_create_room( room_id, RoomState::Joined, @@ -459,10 +474,15 @@ impl BaseClient { let _sync_lock = self.sync_lock().lock().await; let mut room_info = room.clone_info(); + let previous_state = room.state(); + + room_info.mark_as_joined(); + room_info.mark_state_partially_synced(); + room_info.mark_members_missing(); // the own member event changed // If our previous state was an invite and we're now in the joined state, this - // means that the user has explicitly accepted the invite. Let's - // remember when this has happened. + // means that the user has explicitly accepted an invite. Let's + // remember some details about the invite. // // This is somewhat of a workaround for our lack of cryptographic membership. // Later on we will decide if historic room keys should be accepted @@ -470,14 +490,16 @@ impl BaseClient { // key bundle shortly after, we might accept it. If we don't do // this, the homeserver could trick us into accepting any historic room key // bundle. - if room.state() == RoomState::Invited { - room_info.set_invite_accepted_now(); + if previous_state == RoomState::Invited { + if let Some(inviter) = inviter { + let details = InviteAcceptanceDetails { + invite_accepted_at: MilliSecondsSinceUnixEpoch::now(), + inviter, + }; + room_info.set_invite_acceptance_details(details); + } } - room_info.mark_as_joined(); - room_info.mark_state_partially_synced(); - room_info.mark_members_missing(); // the own member event changed - let mut changes = StateChanges::default(); changes.add_room(room_info.clone()); @@ -1131,7 +1153,7 @@ impl From<&v5::Request> for RequestedRequiredStates { mod tests { use std::collections::HashMap; - use assert_matches2::assert_let; + use assert_matches2::{assert_let, assert_matches}; use futures_util::FutureExt as _; use matrix_sdk_test::{ async_test, event_factory::EventFactory, ruma_response_from_json, InvitedRoomBuilder, @@ -1739,8 +1761,9 @@ mod tests { } #[async_test] - async fn test_joined_at_timestamp_is_set() { - let client = logged_in_base_client(None).await; + async fn test_invite_details_are_set() { + let user_id = user_id!("@alice:localhost"); + let client = logged_in_base_client(Some(user_id)).await; let invited_room_id = room_id!("!invited:localhost"); let unknown_room_id = room_id!("!unknown:localhost"); @@ -1757,27 +1780,41 @@ mod tests { .expect("The sync should have created a room in the invited state"); assert_eq!(invited_room.state(), RoomState::Invited); - assert!(invited_room.inner.get().invite_accepted_at().is_none()); + assert!(invited_room.invite_acceptance_details().is_none()); // Now we join the room. let joined_room = client - .room_joined(invited_room_id) + .room_joined(invited_room_id, Some(user_id.to_owned())) .await .expect("We should be able to mark a room as joined"); - // Yup, there's a timestamp now. + // Yup, we now have some invite details. assert_eq!(joined_room.state(), RoomState::Joined); - assert!(joined_room.inner.get().invite_accepted_at().is_some()); + assert_matches!(joined_room.invite_acceptance_details(), Some(details)); + assert_eq!(details.inviter, user_id); // If we didn't know about the room before the join, we assume that there wasn't // an invite and we don't record the timestamp. assert!(client.get_room(unknown_room_id).is_none()); let unknown_room = client - .room_joined(unknown_room_id) + .room_joined(unknown_room_id, Some(user_id.to_owned())) .await .expect("We should be able to mark a room as joined"); assert_eq!(unknown_room.state(), RoomState::Joined); - assert!(unknown_room.inner.get().invite_accepted_at().is_none()); + assert!(unknown_room.invite_acceptance_details().is_none()); + + sync_builder.clear(); + let response = + sync_builder.add_left_room(LeftRoomBuilder::new(invited_room_id)).build_sync_response(); + client.receive_sync_response(response).await.unwrap(); + + // Now that we left the room, we shouldn't have any details anymore. + let left_room = client + .get_room(invited_room_id) + .expect("The sync should have created a room in the invited state"); + + assert_eq!(left_room.state(), RoomState::Left); + assert!(left_room.invite_acceptance_details().is_none()); } } diff --git a/crates/matrix-sdk-base/src/lib.rs b/crates/matrix-sdk-base/src/lib.rs index df08f089ace..ad6e7eab909 100644 --- a/crates/matrix-sdk-base/src/lib.rs +++ b/crates/matrix-sdk-base/src/lib.rs @@ -55,9 +55,10 @@ pub use http; pub use matrix_sdk_crypto as crypto; pub use once_cell; pub use room::{ - apply_redaction, EncryptionState, PredecessorRoom, Room, RoomCreateWithCreatorEventContent, - RoomDisplayName, RoomHero, RoomInfo, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, - RoomMember, RoomMembersUpdate, RoomMemberships, RoomState, RoomStateFilter, SuccessorRoom, + apply_redaction, EncryptionState, InviteAcceptanceDetails, PredecessorRoom, Room, + RoomCreateWithCreatorEventContent, RoomDisplayName, RoomHero, RoomInfo, RoomInfoNotableUpdate, + RoomInfoNotableUpdateReasons, RoomMember, RoomMembersUpdate, RoomMemberships, RoomState, + RoomStateFilter, SuccessorRoom, }; pub use store::{ ComposerDraft, ComposerDraftType, QueueWedgeError, StateChanges, StateStore, StateStoreDataKey, diff --git a/crates/matrix-sdk-base/src/room/mod.rs b/crates/matrix-sdk-base/src/room/mod.rs index 7797ed57eed..f96664a3fca 100644 --- a/crates/matrix-sdk-base/src/room/mod.rs +++ b/crates/matrix-sdk-base/src/room/mod.rs @@ -44,7 +44,8 @@ use matrix_sdk_common::ring_buffer::RingBuffer; pub use members::{RoomMember, RoomMembersUpdate, RoomMemberships}; pub(crate) use room_info::SyncInfo; pub use room_info::{ - apply_redaction, BaseRoomInfo, RoomInfo, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, + apply_redaction, BaseRoomInfo, InviteAcceptanceDetails, RoomInfo, RoomInfoNotableUpdate, + RoomInfoNotableUpdateReasons, }; #[cfg(feature = "e2e-encryption")] use ruma::{events::AnySyncTimelineEvent, serde::Raw}; @@ -457,6 +458,17 @@ impl Room { self.inner.read().recency_stamp } + /// Returns the details about an invite to this room if the invite has been + /// accepted by this specific client. + /// + /// # Returns + /// - `Some` if an invite has been accepted by this specific client. + /// - `None` if we didn't join this room using an invite or the invite + /// wasn't accepted by this client. + pub fn invite_acceptance_details(&self) -> Option { + self.inner.read().invite_acceptance_details.clone() + } + /// Get a `Stream` of loaded pinned events for this room. /// If no pinned events are found a single empty `Vec` will be returned. pub fn pinned_event_ids_stream(&self) -> impl Stream> { diff --git a/crates/matrix-sdk-base/src/room/room_info.rs b/crates/matrix-sdk-base/src/room/room_info.rs index 525de26b577..b9d281efa1a 100644 --- a/crates/matrix-sdk-base/src/room/room_info.rs +++ b/crates/matrix-sdk-base/src/room/room_info.rs @@ -50,7 +50,7 @@ use ruma::{ OwnedRoomId, OwnedUserId, RoomAliasId, RoomId, RoomVersionId, UserId, }; use serde::{Deserialize, Serialize}; -use tracing::{debug, field::debug, info, instrument, warn}; +use tracing::{debug, error, field::debug, info, instrument, warn}; use super::{ AccountDataSource, EncryptionState, Room, RoomCreateWithCreatorEventContent, RoomDisplayName, @@ -66,6 +66,18 @@ use crate::{ MinimalStateEvent, OriginalMinimalStateEvent, }; +/// A struct remembering details of an invite and if the invite has been +/// accepted on this particular client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteAcceptanceDetails { + /// A timestamp remembering when we observed the user accepting an invite + /// using this client. + pub invite_accepted_at: MilliSecondsSinceUnixEpoch, + + /// The user ID of the person that invited us. + pub inviter: OwnedUserId, +} + impl Room { /// Subscribe to the inner `RoomInfo`. pub fn subscribe_info(&self) -> Subscriber { @@ -471,7 +483,7 @@ pub struct RoomInfo { /// This is useful to remember if the user accepted this a join on this /// specific client. #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) invite_accepted_at: Option, + pub(crate) invite_acceptance_details: Option, } impl RoomInfo { @@ -494,7 +506,7 @@ impl RoomInfo { cached_display_name: None, cached_user_defined_notification_mode: None, recency_stamp: None, - invite_accepted_at: None, + invite_acceptance_details: None, } } @@ -525,6 +537,12 @@ impl RoomInfo { /// Set the membership RoomState of this Room pub fn set_state(&mut self, room_state: RoomState) { + if self.state() != RoomState::Joined && self.invite_acceptance_details.is_some() { + error!(room_id = %self.room_id, "The RoomInfo contains invite acceptance details but the room is not in the joined state"); + } + // Changing our state removes the invite details since we can't know that they + // are relevant anymore. + self.invite_acceptance_details = None; self.room_state = room_state; } @@ -758,10 +776,8 @@ impl RoomInfo { self.summary.invited_member_count = count; } - /// Mark that the user has accepted an invite and remember when this has - /// happened using a timestamp set to [`MilliSecondsSinceUnixEpoch::now()`]. - pub(crate) fn set_invite_accepted_now(&mut self) { - self.invite_accepted_at = Some(MilliSecondsSinceUnixEpoch::now()); + pub(crate) fn set_invite_acceptance_details(&mut self, details: InviteAcceptanceDetails) { + self.invite_acceptance_details = Some(details); } /// Returns the timestamp when an invite to this room has been accepted by @@ -770,8 +786,8 @@ impl RoomInfo { /// # Returns /// - `Some` if the invite has been accepted by this specific client. /// - `None` if the invite has not been accepted - pub fn invite_accepted_at(&self) -> Option { - self.invite_accepted_at + pub fn invite_acceptance_details(&self) -> Option { + self.invite_acceptance_details.clone() } /// Updates the room heroes. @@ -1247,7 +1263,7 @@ mod tests { cached_display_name: None, cached_user_defined_notification_mode: None, recency_stamp: Some(42), - invite_accepted_at: None, + invite_acceptance_details: None, }; let info_json = json!({ diff --git a/crates/matrix-sdk-base/src/store/migration_helpers.rs b/crates/matrix-sdk-base/src/store/migration_helpers.rs index 0006a390f31..5530eb8ca1c 100644 --- a/crates/matrix-sdk-base/src/store/migration_helpers.rs +++ b/crates/matrix-sdk-base/src/store/migration_helpers.rs @@ -121,7 +121,7 @@ impl RoomInfoV1 { cached_display_name: None, cached_user_defined_notification_mode: None, recency_stamp: None, - invite_accepted_at: None, + invite_acceptance_details: None, } } } diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 9086c029399..82c12e7cff8 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -1480,7 +1480,16 @@ impl Client { false }; - let base_room = self.base_client().room_joined(room_id).await?; + let base_room = self + .base_client() + .room_joined( + room_id, + pre_join_room_info + .as_ref() + .and_then(|info| info.inviter.as_ref()) + .map(|i| i.user_id().to_owned()), + ) + .await?; let room = Room::new(self.clone(), base_room); if mark_as_dm {