Skip to content

feat(base): Remember when a user accepts an invite #5333

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 3 commits into from
Jul 7, 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/matrix-sdk-base/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased] - ReleaseDate

### Features
- The `RoomInfo` now remembers when an invite was explicitly accepted when the
`BaseClient::room_joined()` method was called. A new getter for this
timestamp exists, the `RoomInfo::invite_accepted_at()` method returns this
timestamp.
([#5333](https://github.com/matrix-org/matrix-rust-sdk/pull/5333))

### Refactor

- The cached `ServerCapabilities` has been renamed to `ServerInfo` and
Expand Down
1 change: 1 addition & 0 deletions crates/matrix-sdk-base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ unicode-normalization.workspace = true
uniffi = { workspace = true, optional = true }

[dev-dependencies]
anyhow.workspace = true
assert_matches.workspace = true
assert_matches2.workspace = true
assign = "1.1.1"
Expand Down
89 changes: 88 additions & 1 deletion crates/matrix-sdk-base/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,26 +394,70 @@ impl BaseClient {
Ok(room)
}

/// User has joined a room.
/// The user has joined a room using this specific client.
///
/// This method should be called if the user accepts an invite or if they
/// join a public room.
///
/// The method will create a [`Room`] object if one does not exist yet and
/// set the state of the [`Room`] to [`RoomState::Joined`]. The [`Room`]
/// object will be persisted in the cache. Please note that the [`Room`]
/// will be a stub until a sync has been received with the full room
/// state using [`BaseClient::receive_sync_response`].
///
/// Update the internal and cached state accordingly. Return the final Room.
///
/// # Examples
///
/// ```rust
/// # use matrix_sdk_base::{BaseClient, store::StoreConfig, RoomState};
/// # use ruma::OwnedRoomId;
/// # async {
/// # let client = BaseClient::new(StoreConfig::new("example".to_owned()));
/// # async fn send_join_request() -> anyhow::Result<OwnedRoomId> { todo!() }
/// let room_id = send_join_request().await?;
/// let room = client.room_joined(&room_id).await?;
///
/// assert_eq!(room.state(), RoomState::Joined);
/// # anyhow::Ok(()) };
/// ```
pub async fn room_joined(&self, room_id: &RoomId) -> Result<Room> {
let room = self.state_store.get_or_create_room(
room_id,
RoomState::Joined,
self.room_info_notable_update_sender.clone(),
);

// If the state isn't `RoomState::Joined` then this means that we knew about
// this room before. Let's modify the existing state now.
if room.state() != RoomState::Joined {
let _sync_lock = self.sync_lock().lock().await;

let mut room_info = room.clone_info();

// 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.
//
// 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();
}

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());

self.state_store.save_changes(&changes).await?; // Update the store

room.set_room_info(room_info, RoomInfoNotableUpdateReasons::MEMBERSHIP);
}

Expand Down Expand Up @@ -1659,4 +1703,47 @@ mod tests {

assert!(client.is_user_ignored(ignored_user_id).await);
}

#[async_test]
async fn test_joined_at_timestamp_is_set() {
let client = logged_in_base_client(None).await;
let invited_room_id = room_id!("!invited:localhost");
let unknown_room_id = room_id!("!unknown:localhost");

let mut sync_builder = SyncResponseBuilder::new();
let response = sync_builder
.add_invited_room(InvitedRoomBuilder::new(invited_room_id))
.build_sync_response();
client.receive_sync_response(response).await.unwrap();

// Let us first check the initial state, we should have a room in the invite
// state.
let invited_room = client
.get_room(invited_room_id)
.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());

// Now we join the room.
let joined_room = client
.room_joined(invited_room_id)
.await
.expect("We should be able to mark a room as joined");

// Yup, there's a timestamp now.
assert_eq!(joined_room.state(), RoomState::Joined);
assert!(joined_room.inner.get().invite_accepted_at().is_some());

// 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)
.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());
}
}
31 changes: 29 additions & 2 deletions crates/matrix-sdk-base/src/room/room_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ use ruma::{
},
room::RoomType,
serde::Raw,
EventId, MxcUri, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId,
RoomAliasId, RoomId, RoomVersionId, UserId,
EventId, MilliSecondsSinceUnixEpoch, MxcUri, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId,
OwnedRoomId, OwnedUserId, RoomAliasId, RoomId, RoomVersionId, UserId,
};
use serde::{Deserialize, Serialize};
use tracing::{debug, field::debug, info, instrument, warn};
Expand Down Expand Up @@ -464,6 +464,14 @@ pub struct RoomInfo {
/// more accurate than relying on the latest event.
#[serde(default)]
pub(crate) recency_stamp: Option<u64>,

/// A timestamp remembering when we observed the user accepting an invite on
/// this current device.
///
/// 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>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I never do know how these work: does it need a serde default or some special migration to not break old instances?

Copy link
Contributor Author

@poljar poljar Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=dfb0cddf843b3f7eb2bda51fe094b5e1

A missing field or a null are treated the same if Option is used. I guess we could add a skip_serializing here since this field will in most cases be None.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

impl RoomInfo {
Expand All @@ -486,6 +494,7 @@ impl RoomInfo {
cached_display_name: None,
cached_user_defined_notification_mode: None,
recency_stamp: None,
invite_accepted_at: None,
}
}

Expand Down Expand Up @@ -749,6 +758,22 @@ 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());
}

/// Returns the timestamp when an invite to this room has been accepted by
/// this specific client.
///
/// # 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
}

/// Updates the room heroes.
pub(crate) fn update_heroes(&mut self, heroes: Vec<RoomHero>) {
self.summary.room_heroes = heroes;
Expand Down Expand Up @@ -1173,6 +1198,7 @@ mod tests {
owned_mxc_uri, owned_user_id, room_id, serde::Raw,
};
use serde_json::json;
use similar_asserts::assert_eq;

use super::{BaseRoomInfo, RoomInfo, SyncInfo};
use crate::{
Expand Down Expand Up @@ -1221,6 +1247,7 @@ mod tests {
cached_display_name: None,
cached_user_defined_notification_mode: None,
recency_stamp: Some(42),
invite_accepted_at: None,
};

let info_json = json!({
Expand Down
1 change: 1 addition & 0 deletions crates/matrix-sdk-base/src/store/migration_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ impl RoomInfoV1 {
cached_display_name: None,
cached_user_defined_notification_mode: None,
recency_stamp: None,
invite_accepted_at: None,
}
}
}
Expand Down
Loading