matrix_sdk/room/
knock_requests.rs

1// Copyright 2024 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use js_int::UInt;
16use ruma::{EventId, OwnedEventId, OwnedMxcUri, OwnedUserId, RoomId};
17
18use crate::{room::RoomMember, Error, Room};
19
20/// A request to join a room with `knock` join rule.
21#[derive(Debug, Clone)]
22pub struct KnockRequest {
23    room: Room,
24    /// The event id of the event containing knock membership change.
25    pub event_id: OwnedEventId,
26    /// The timestamp when this request was created.
27    pub timestamp: Option<UInt>,
28    /// Some general room member info to display.
29    pub member_info: KnockRequestMemberInfo,
30    /// Whether it's been marked as 'seen' by the client.
31    pub is_seen: bool,
32}
33
34impl KnockRequest {
35    pub(crate) fn new(
36        room: &Room,
37        event_id: &EventId,
38        timestamp: Option<UInt>,
39        member: KnockRequestMemberInfo,
40        is_seen: bool,
41    ) -> Self {
42        Self {
43            room: room.clone(),
44            event_id: event_id.to_owned(),
45            timestamp,
46            member_info: member,
47            is_seen,
48        }
49    }
50
51    /// The room id for the `Room` from whose access is requested.
52    pub fn room_id(&self) -> &RoomId {
53        self.room.room_id()
54    }
55
56    /// Marks the knock request as 'seen' so the client can ignore it in the
57    /// future.
58    pub async fn mark_as_seen(&self) -> Result<(), Error> {
59        self.room.mark_knock_requests_as_seen(&[self.member_info.user_id.to_owned()]).await?;
60        Ok(())
61    }
62
63    /// Accepts the knock request by inviting the user to the room.
64    pub async fn accept(&self) -> Result<(), Error> {
65        self.room.invite_user_by_id(&self.member_info.user_id).await
66    }
67
68    /// Declines the knock request by kicking the user from the room, with an
69    /// optional reason.
70    pub async fn decline(&self, reason: Option<&str>) -> Result<(), Error> {
71        self.room.kick_user(&self.member_info.user_id, reason).await
72    }
73
74    /// Declines the knock request by banning the user from the room, with an
75    /// optional reason.
76    pub async fn decline_and_ban(&self, reason: Option<&str>) -> Result<(), Error> {
77        self.room.ban_user(&self.member_info.user_id, reason).await
78    }
79}
80
81/// General room member info to display along with the join request.
82#[derive(Debug, Clone)]
83pub struct KnockRequestMemberInfo {
84    /// The user id for the room member requesting access.
85    pub user_id: OwnedUserId,
86    /// The optional display name of the room member requesting access.
87    pub display_name: Option<String>,
88    /// The optional avatar url of the room member requesting access.
89    pub avatar_url: Option<OwnedMxcUri>,
90    /// An optional reason why the user wants access to the room.
91    pub reason: Option<String>,
92}
93
94impl KnockRequestMemberInfo {
95    pub(crate) fn from_member(member: &RoomMember) -> Self {
96        Self {
97            user_id: member.user_id().to_owned(),
98            display_name: member.display_name().map(ToOwned::to_owned),
99            avatar_url: member.avatar_url().map(ToOwned::to_owned),
100            reason: member.event().reason().map(ToOwned::to_owned),
101        }
102    }
103}
104
105// The http mocking library is not supported for wasm32
106#[cfg(all(test, not(target_arch = "wasm32")))]
107mod tests {
108    use matrix_sdk_test::{async_test, event_factory::EventFactory, JoinedRoomBuilder};
109    use ruma::{
110        event_id, events::room::member::MembershipState, owned_user_id, room_id, user_id, EventId,
111    };
112
113    use crate::{
114        room::knock_requests::{KnockRequest, KnockRequestMemberInfo},
115        test_utils::mocks::MatrixMockServer,
116        Room,
117    };
118
119    #[async_test]
120    async fn test_mark_as_seen() {
121        let server = MatrixMockServer::new().await;
122        let client = server.client_builder().build().await;
123        let room_id = room_id!("!a:b.c");
124        let event_id = event_id!("$a:b.c");
125        let user_id = user_id!("@alice:b.c");
126
127        let f = EventFactory::new().room(room_id);
128        let joined_room_builder = JoinedRoomBuilder::new(room_id).add_state_bulk(vec![f
129            .member(user_id)
130            .membership(MembershipState::Knock)
131            .event_id(event_id)
132            .into_raw_timeline()
133            .cast()]);
134        let room = server.sync_room(&client, joined_room_builder).await;
135
136        let knock_request = make_knock_request(&room, Some(event_id));
137
138        // When we mark the knock request as seen
139        knock_request.mark_as_seen().await.expect("Failed to mark as seen");
140
141        // Then we can check it was successfully marked as seen from the room
142        let seen_ids =
143            room.get_seen_knock_request_ids().await.expect("Failed to get seen join request ids");
144        assert_eq!(seen_ids.len(), 1);
145        assert_eq!(
146            seen_ids.into_iter().next().expect("Couldn't load next item"),
147            (event_id.to_owned(), user_id.to_owned())
148        );
149    }
150
151    #[async_test]
152    async fn test_accept() {
153        let server = MatrixMockServer::new().await;
154        let client = server.client_builder().build().await;
155        let room_id = room_id!("!a:b.c");
156
157        let room = server.sync_joined_room(&client, room_id).await;
158
159        let knock_request = make_knock_request(&room, None);
160
161        // The /invite endpoint must be called once
162        server.mock_invite_user_by_id().ok().mock_once().mount().await;
163
164        // When we accept the knock request
165        knock_request.accept().await.expect("Failed to accept the request");
166    }
167
168    #[async_test]
169    async fn test_decline() {
170        let server = MatrixMockServer::new().await;
171        let client = server.client_builder().build().await;
172        let room_id = room_id!("!a:b.c");
173
174        let room = server.sync_joined_room(&client, room_id).await;
175
176        let knock_request = make_knock_request(&room, None);
177
178        // The /kick endpoint must be called once
179        server.mock_kick_user().ok().mock_once().mount().await;
180
181        // When we decline the knock request
182        knock_request.decline(None).await.expect("Failed to decline the request");
183    }
184
185    #[async_test]
186    async fn test_decline_and_ban() {
187        let server = MatrixMockServer::new().await;
188        let client = server.client_builder().build().await;
189        let room_id = room_id!("!a:b.c");
190
191        let room = server.sync_joined_room(&client, room_id).await;
192
193        let knock_request = make_knock_request(&room, None);
194
195        // The /ban endpoint must be called once
196        server.mock_ban_user().ok().mock_once().mount().await;
197
198        // When we decline the knock request and ban the user from the room
199        knock_request
200            .decline_and_ban(None)
201            .await
202            .expect("Failed to decline the request and ban the user");
203    }
204
205    fn make_knock_request(room: &Room, event_id: Option<&EventId>) -> KnockRequest {
206        KnockRequest::new(
207            room,
208            event_id.unwrap_or(event_id!("$a:b.c")),
209            None,
210            KnockRequestMemberInfo {
211                user_id: owned_user_id!("@alice:b.c"),
212                display_name: None,
213                avatar_url: None,
214                reason: None,
215            },
216            false,
217        )
218    }
219}