From 1e35d522ada8ff413a204ccae562aa986b21f09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Dec 2024 17:47:29 +0100 Subject: [PATCH] feat(room): add `JoinRequest` abstraction This struct is an abstraction over a room member or state event with knock membership. --- .../src/deserialized_responses.rs | 19 +- crates/matrix-sdk/src/room/mod.rs | 2 + crates/matrix-sdk/src/room/request_to_join.rs | 205 ++++++++++++++++++ crates/matrix-sdk/src/test_utils/mocks.rs | 123 +++++++++++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 crates/matrix-sdk/src/room/request_to_join.rs diff --git a/crates/matrix-sdk-base/src/deserialized_responses.rs b/crates/matrix-sdk-base/src/deserialized_responses.rs index 183a02da531..1f4bac92903 100644 --- a/crates/matrix-sdk-base/src/deserialized_responses.rs +++ b/crates/matrix-sdk-base/src/deserialized_responses.rs @@ -30,7 +30,7 @@ use ruma::{ StateEventContent, StaticStateEventContent, StrippedStateEvent, SyncStateEvent, }, serde::Raw, - EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, UserId, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, UInt, UserId, }; use serde::Serialize; use unicode_normalization::UnicodeNormalization; @@ -476,6 +476,23 @@ impl MemberEvent { .unwrap_or_else(|| self.user_id().localpart()), ) } + + /// The optional reason why the membership changed. + pub fn reason(&self) -> Option<&str> { + match self { + MemberEvent::Sync(SyncStateEvent::Original(c)) => c.content.reason.as_deref(), + MemberEvent::Stripped(e) => e.content.reason.as_deref(), + _ => None, + } + } + + /// The optional timestamp for this member event. + pub fn timestamp(&self) -> Option { + match self { + MemberEvent::Sync(SyncStateEvent::Original(c)) => Some(c.origin_server_ts.0), + _ => None, + } + } } impl SyncOrStrippedState { diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index a9a67d702b6..a257c4fe3c9 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -149,6 +149,8 @@ pub mod identity_status_changes; mod member; mod messages; pub mod power_levels; +/// Contains code related to requests to join a room. +pub mod request_to_join; /// A struct containing methods that are common for Joined, Invited and Left /// Rooms diff --git a/crates/matrix-sdk/src/room/request_to_join.rs b/crates/matrix-sdk/src/room/request_to_join.rs new file mode 100644 index 00000000000..ff1b8dfe5af --- /dev/null +++ b/crates/matrix-sdk/src/room/request_to_join.rs @@ -0,0 +1,205 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use js_int::UInt; +use ruma::{EventId, OwnedEventId, OwnedMxcUri, OwnedUserId, RoomId}; + +use crate::{room::RoomMember, Error, Room}; + +/// A request to join a room with `knock` join rule. +#[derive(Debug, Clone)] +pub struct JoinRequest { + room: Room, + /// The event id of the event containing knock membership change. + pub event_id: OwnedEventId, + /// The timestamp when this request was created. + pub timestamp: Option, + /// Some general room member info to display. + pub member_info: RequestToJoinMemberInfo, + /// Whether it's been marked as 'seen' by the client. + pub is_seen: bool, +} + +impl JoinRequest { + pub(crate) fn new( + room: &Room, + event_id: &EventId, + timestamp: Option, + member: RequestToJoinMemberInfo, + is_seen: bool, + ) -> Self { + Self { + room: room.clone(), + event_id: event_id.to_owned(), + timestamp, + member_info: member, + is_seen, + } + } + + /// The room id for the `Room` form whose access is requested. + pub fn room_id(&self) -> &RoomId { + self.room.room_id() + } + + /// Marks the request to join as 'seen' so the client can ignore it in the + /// future. + pub async fn mark_as_seen(&self) -> Result<(), Error> { + self.room.mark_join_requests_as_seen(&[self.event_id.to_owned()]).await?; + Ok(()) + } + + /// Accepts the request to join by inviting the user to the room. + pub async fn accept(&self) -> Result<(), Error> { + self.room.invite_user_by_id(&self.member_info.user_id).await + } + + /// Declines the request to join by kicking the user from the room, with an + /// optional reason. + pub async fn decline(&self, reason: Option<&str>) -> Result<(), Error> { + self.room.kick_user(&self.member_info.user_id, reason).await + } + + /// Declines the request to join by banning the user from the room, with an + /// optional reason. + pub async fn decline_and_ban(&self, reason: Option<&str>) -> Result<(), Error> { + self.room.ban_user(&self.member_info.user_id, reason).await + } +} + +/// General room member info to display along with the join request. +#[derive(Debug, Clone)] +pub struct RequestToJoinMemberInfo { + /// The user id for the room member requesting access. + pub user_id: OwnedUserId, + /// The optional display name of the room member requesting access. + pub display_name: Option, + /// The optional avatar url of the room member requesting access. + pub avatar_url: Option, + /// An optional reason why the user wants access to the room. + pub reason: Option, +} + +impl From for RequestToJoinMemberInfo { + fn from(member: RoomMember) -> Self { + Self { + user_id: member.user_id().to_owned(), + display_name: member.display_name().map(ToOwned::to_owned), + avatar_url: member.avatar_url().map(ToOwned::to_owned), + reason: member.event().reason().map(ToOwned::to_owned), + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use matrix_sdk_test::async_test; + use ruma::{event_id, owned_user_id, room_id, EventId}; + + use crate::{ + room::request_to_join::{JoinRequest, RequestToJoinMemberInfo}, + test_utils::mocks::MatrixMockServer, + Room, + }; + + #[async_test] + async fn test_mark_as_seen() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!a:b.c"); + let event_id = event_id!("$a:b.c"); + + let room = server.sync_joined_room(&client, room_id).await; + + let join_request = mock_join_request(&room, Some(event_id)); + + // When we mark the join request as seen + join_request.mark_as_seen().await.expect("Failed to mark as seen"); + + // Then we can check it was successfully marked as seen from the room + let seen_ids = + room.get_seen_join_request_ids().await.expect("Failed to get seen join request ids"); + assert_eq!(seen_ids.len(), 1); + assert_eq!(seen_ids.into_iter().next().expect("Couldn't load next item"), event_id); + } + + #[async_test] + async fn test_accept() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!a:b.c"); + + let room = server.sync_joined_room(&client, room_id).await; + + let join_request = mock_join_request(&room, None); + + // The /invite endpoint must be called once + server.mock_invite_user_by_id().ok().mock_once().mount().await; + + // When we accept the join request + join_request.accept().await.expect("Failed to accept the request"); + } + + #[async_test] + async fn test_decline() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!a:b.c"); + + let room = server.sync_joined_room(&client, room_id).await; + + let join_request = mock_join_request(&room, None); + + // The /kick endpoint must be called once + server.mock_kick_user().ok().mock_once().mount().await; + + // When we decline the join request + join_request.decline(None).await.expect("Failed to decline the request"); + } + + #[async_test] + async fn test_decline_and_ban() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let room_id = room_id!("!a:b.c"); + + let room = server.sync_joined_room(&client, room_id).await; + + let join_request = mock_join_request(&room, None); + + // The /ban endpoint must be called once + server.mock_ban_user().ok().mock_once().mount().await; + + // When we decline the join request and ban the user from the room + join_request + .decline_and_ban(None) + .await + .expect("Failed to decline the request and ban the user"); + } + + fn mock_join_request(room: &Room, event_id: Option<&EventId>) -> JoinRequest { + JoinRequest::new( + room, + event_id.unwrap_or(event_id!("$a:b.c")), + None, + RequestToJoinMemberInfo { + user_id: owned_user_id!("@alice:b.c"), + display_name: None, + avatar_url: None, + reason: None, + }, + false, + ) + } +} diff --git a/crates/matrix-sdk/src/test_utils/mocks.rs b/crates/matrix-sdk/src/test_utils/mocks.rs index 74d25a5ac01..28fbf9b91e7 100644 --- a/crates/matrix-sdk/src/test_utils/mocks.rs +++ b/crates/matrix-sdk/src/test_utils/mocks.rs @@ -607,6 +607,95 @@ impl MatrixMockServer { .and(header("authorization", "Bearer 1234")); MockEndpoint { mock, server: &self.server, endpoint: DeleteRoomKeysVersionEndpoint } } + + /// Creates a prebuilt mock for inviting a user to a room by its id. + /// + /// # Examples + /// + /// ``` + /// # use ruma::user_id; + /// tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::room_id, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server.mock_invite_user_by_id().ok().mock_once().mount().await; + /// + /// let room = mock_server + /// .sync_joined_room(&client, room_id!("!room_id:localhost")) + /// .await; + /// + /// room.invite_user_by_id(user_id!("@alice:localhost")).await.unwrap(); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_invite_user_by_id(&self) -> MockEndpoint<'_, InviteUserByIdEndpoint> { + let mock = + Mock::given(method("POST")).and(path_regex(r"^/_matrix/client/v3/rooms/.*/invite$")); + MockEndpoint { mock, server: &self.server, endpoint: InviteUserByIdEndpoint } + } + + /// Creates a prebuilt mock for kicking a user from a room. + /// + /// # Examples + /// + /// ``` + /// # use ruma::user_id; + /// tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::room_id, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server.mock_kick_user().ok().mock_once().mount().await; + /// + /// let room = mock_server + /// .sync_joined_room(&client, room_id!("!room_id:localhost")) + /// .await; + /// + /// room.kick_user(user_id!("@alice:localhost"), None).await.unwrap(); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_kick_user(&self) -> MockEndpoint<'_, KickUserEndpoint> { + let mock = + Mock::given(method("POST")).and(path_regex(r"^/_matrix/client/v3/rooms/.*/kick")); + MockEndpoint { mock, server: &self.server, endpoint: KickUserEndpoint } + } + + /// Creates a prebuilt mock for banning a user from a room. + /// + /// # Examples + /// + /// ``` + /// # use ruma::user_id; + /// tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::room_id, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server.mock_ban_user().ok().mock_once().mount().await; + /// + /// let room = mock_server + /// .sync_joined_room(&client, room_id!("!room_id:localhost")) + /// .await; + /// + /// room.ban_user(user_id!("@alice:localhost"), None).await.unwrap(); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_ban_user(&self) -> MockEndpoint<'_, BanUserEndpoint> { + let mock = Mock::given(method("POST")).and(path_regex(r"^/_matrix/client/v3/rooms/.*/ban")); + MockEndpoint { mock, server: &self.server, endpoint: BanUserEndpoint } + } } /// Parameter to [`MatrixMockServer::sync_room`]. @@ -1761,3 +1850,37 @@ impl<'a> MockEndpoint<'a, DeleteRoomKeysVersionEndpoint> { MatrixMock { server: self.server, mock } } } + + +/// A prebuilt mock for `POST /invite` request. +pub struct InviteUserByIdEndpoint; + +impl<'a> MockEndpoint<'a, InviteUserByIdEndpoint> { + /// Returns a successful invite user by id request. + pub fn ok(self) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({}))); + MatrixMock { server: self.server, mock } + } +} + +/// A prebuilt mock for `POST /kick` request. +pub struct KickUserEndpoint; + +impl<'a> MockEndpoint<'a, KickUserEndpoint> { + /// Returns a successful kick user request. + pub fn ok(self) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({}))); + MatrixMock { server: self.server, mock } + } +} + +/// A prebuilt mock for `POST /ban` request. +pub struct BanUserEndpoint; + +impl<'a> MockEndpoint<'a, BanUserEndpoint> { + /// Returns a successful ban user request. + pub fn ok(self) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({}))); + MatrixMock { server: self.server, mock } + } +}