Skip to content

Commit

Permalink
feat(room): add JoinRequest abstraction
Browse files Browse the repository at this point in the history
This struct is an abstraction over a room member or state event with knock membership.
  • Loading branch information
jmartinesp committed Dec 10, 2024
1 parent df46f24 commit 1e35d52
Show file tree
Hide file tree
Showing 4 changed files with 348 additions and 1 deletion.
19 changes: 18 additions & 1 deletion crates/matrix-sdk-base/src/deserialized_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<UInt> {
match self {
MemberEvent::Sync(SyncStateEvent::Original(c)) => Some(c.origin_server_ts.0),
_ => None,
}
}
}

impl SyncOrStrippedState<RoomPowerLevelsEventContent> {
Expand Down
2 changes: 2 additions & 0 deletions crates/matrix-sdk/src/room/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions crates/matrix-sdk/src/room/request_to_join.rs
Original file line number Diff line number Diff line change
@@ -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<UInt>,
/// 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<UInt>,
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<String>,
/// The optional avatar url of the room member requesting access.
pub avatar_url: Option<OwnedMxcUri>,
/// An optional reason why the user wants access to the room.
pub reason: Option<String>,
}

impl From<RoomMember> 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,
)
}
}
123 changes: 123 additions & 0 deletions crates/matrix-sdk/src/test_utils/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -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 }
}
}

0 comments on commit 1e35d52

Please sign in to comment.