Skip to content

feat(base): Remember the inviter if we accept an invite #5390

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/matrix-sdk-base/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 59 additions & 22 deletions crates/matrix-sdk-base/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -66,7 +66,7 @@ use crate::{
StateStoreDataValue, StateStoreExt, StoreConfig,
},
sync::{RoomUpdates, SyncResponse},
RoomStateFilter, SessionMeta,
InviteAcceptanceDetails, RoomStateFilter, SessionMeta,
};

/// A no (network) IO client implementation.
Expand Down Expand Up @@ -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<OwnedRoomId> { todo!() }
/// # async fn maybe_get_inviter(room_id: &RoomId) -> anyhow::Result<Option<OwnedUserId>> { 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<Room> {
pub async fn room_joined(
&self,
room_id: &RoomId,
inviter: Option<OwnedUserId>,
) -> Result<Room> {
let room = self.state_store.get_or_create_room(
room_id,
RoomState::Joined,
Expand All @@ -459,25 +474,32 @@ 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
// based on this info. If a user has accepted an invite and we receive a room
// 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());

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");

Expand All @@ -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());
}
}
7 changes: 4 additions & 3 deletions crates/matrix-sdk-base/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion crates/matrix-sdk-base/src/room/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<InviteAcceptanceDetails> {
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<Item = Vec<OwnedEventId>> {
Expand Down
36 changes: 26 additions & 10 deletions crates/matrix-sdk-base/src/room/room_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RoomInfo> {
Expand Down Expand Up @@ -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<MilliSecondsSinceUnixEpoch>,
pub(crate) invite_acceptance_details: Option<InviteAcceptanceDetails>,
}

impl RoomInfo {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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<MilliSecondsSinceUnixEpoch> {
self.invite_accepted_at
pub fn invite_acceptance_details(&self) -> Option<InviteAcceptanceDetails> {
self.invite_acceptance_details.clone()
}

/// Updates the room heroes.
Expand Down Expand Up @@ -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!({
Expand Down
2 changes: 1 addition & 1 deletion crates/matrix-sdk-base/src/store/migration_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion crates/matrix-sdk/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading