matrix_sdk_ui/spaces/
leave.rs

1// Copyright 2025 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 that specific language governing permissions and
13// limitations under the License.
14
15use matrix_sdk::{Client, RoomState, room::RoomMemberRole};
16use ruma::{Int, OwnedRoomId};
17
18use crate::spaces::{Error, SpaceRoom};
19
20/// Space leaving specific room that groups normal [`SpaceRoom`] details with
21/// information about the leaving user's role.
22#[derive(Debug, Clone)]
23pub struct LeaveSpaceRoom {
24    /// The underlying [`SpaceRoom`]
25    pub space_room: SpaceRoom,
26    /// Whether the user is the last admin in the room. This helps clients
27    /// better inform the user about the consequences of leaving the room.
28    pub is_last_admin: bool,
29}
30
31/// The `LeaveSpaceHandle` processes rooms to be left in the order they were
32/// provided by the [`crate::spaces::SpaceService`] and annotates them with
33/// extra data to inform the leave process e.g. if the current user is the last
34/// room admin.
35///
36/// Once the upstream client decides what rooms should actually be left, the
37/// handle provides a method to execute that too.
38pub struct LeaveSpaceHandle {
39    client: Client,
40    rooms: Vec<LeaveSpaceRoom>,
41}
42
43impl LeaveSpaceHandle {
44    pub(crate) async fn new(client: Client, room_ids: Vec<OwnedRoomId>) -> Self {
45        let mut rooms = Vec::new();
46
47        for room_id in &room_ids {
48            let Some(room) = client.get_room(room_id) else {
49                continue;
50            };
51
52            if room.state() != RoomState::Joined {
53                continue;
54            }
55
56            let users_to_power_levels = room.users_with_power_levels().await;
57
58            let is_last_admin = users_to_power_levels
59                .iter()
60                .filter(|(_, power_level)| {
61                    let Some(power_level) = Int::new(**power_level) else {
62                        return false;
63                    };
64
65                    RoomMemberRole::suggested_role_for_power_level(power_level.into())
66                        == RoomMemberRole::Administrator
67                })
68                .map(|p| p.0)
69                .collect::<Vec<_>>()
70                == vec![room.own_user_id()];
71
72            rooms.push(LeaveSpaceRoom {
73                space_room: SpaceRoom::new_from_known(&room, 0),
74                is_last_admin,
75            });
76        }
77
78        Self { client, rooms }
79    }
80
81    /// A list of rooms to be left which next to normal [`SpaceRoom`] data also
82    /// include leave specific information.
83    pub fn rooms(&self) -> &Vec<LeaveSpaceRoom> {
84        &self.rooms
85    }
86
87    /// Bulk leave the given rooms. Stops when encountering an error.
88    pub async fn leave(&self, filter: impl FnMut(&LeaveSpaceRoom) -> bool) -> Result<(), Error> {
89        for room in self.rooms.clone().into_iter().filter(filter) {
90            if let Some(room) = self.client.get_room(&room.space_room.room_id) {
91                room.leave().await.map_err(Error::LeaveSpace)?;
92            } else {
93                return Err(Error::RoomNotFound(room.space_room.room_id));
94            }
95        }
96
97        Ok(())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use std::collections::BTreeMap;
104
105    use matrix_sdk::test_utils::mocks::MatrixMockServer;
106    use matrix_sdk_test::{
107        InvitedRoomBuilder, JoinedRoomBuilder, LeftRoomBuilder, async_test,
108        event_factory::EventFactory,
109    };
110    use ruma::{RoomVersionId, owned_user_id, room_id};
111
112    use crate::spaces::SpaceService;
113
114    #[async_test]
115    async fn test_leaving() {
116        let server = MatrixMockServer::new().await;
117        let client = server.client_builder().build().await;
118        let user_id = client.user_id().unwrap();
119        let space_service = SpaceService::new(client.clone());
120        let factory = EventFactory::new().sender(user_id);
121
122        server.mock_room_state_encryption().plain().mount().await;
123
124        // Given one parent space with 2 children spaces
125
126        let parent_space_id = room_id!("!parent_space:example.org");
127        let child_space_id_1 = room_id!("!child_space_1:example.org");
128        let child_space_id_2 = room_id!("!child_space_2:example.org");
129        let left_room_id = room_id!("!left_room:example.org");
130        let invited_room_id = room_id!("!invited_room:example.org");
131
132        let mut power_levels = BTreeMap::from([
133            (user_id.to_owned(), 100.into()),
134            (owned_user_id!("@some_non_admin:a.b"), 50.into()),
135        ]);
136
137        server
138            .sync_room(
139                &client,
140                JoinedRoomBuilder::new(child_space_id_1)
141                    .add_state_event(factory.create(user_id, RoomVersionId::V1).with_space_type())
142                    .add_state_event(
143                        factory
144                            .space_parent(parent_space_id.to_owned(), child_space_id_1.to_owned()),
145                    )
146                    .add_state_event(
147                        factory.power_levels(&mut power_levels).state_key("").sender(user_id),
148                    ),
149            )
150            .await;
151
152        power_levels.insert(owned_user_id!("@some_other_admin:a.b"), 100.into());
153
154        server
155            .sync_room(
156                &client,
157                JoinedRoomBuilder::new(child_space_id_2)
158                    .add_state_event(factory.create(user_id, RoomVersionId::V1).with_space_type())
159                    .add_state_event(
160                        factory
161                            .space_parent(parent_space_id.to_owned(), child_space_id_2.to_owned()),
162                    )
163                    .add_state_event(
164                        factory.power_levels(&mut power_levels).state_key("").sender(user_id),
165                    ),
166            )
167            .await;
168
169        server.sync_room(&client, LeftRoomBuilder::new(invited_room_id)).await;
170        server.sync_room(&client, InvitedRoomBuilder::new(invited_room_id)).await;
171
172        server
173            .sync_room(
174                &client,
175                JoinedRoomBuilder::new(parent_space_id)
176                    .add_state_event(factory.create(user_id, RoomVersionId::V1).with_space_type())
177                    .add_state_event(
178                        factory
179                            .space_child(parent_space_id.to_owned(), child_space_id_1.to_owned()),
180                    )
181                    .add_state_event(
182                        factory
183                            .space_child(parent_space_id.to_owned(), child_space_id_2.to_owned()),
184                    )
185                    .add_state_event(
186                        factory.space_child(parent_space_id.to_owned(), left_room_id.to_owned()),
187                    )
188                    .add_state_event(
189                        factory.space_child(parent_space_id.to_owned(), invited_room_id.to_owned()),
190                    ),
191            )
192            .await;
193
194        server.mock_room_leave().ok(room_id!("!does_not_matter:a:b")).mount().await;
195
196        assert!(!space_service.joined_spaces().await.is_empty());
197
198        let handle = space_service.leave_space(parent_space_id).await.unwrap();
199
200        let rooms = handle.rooms();
201
202        let child_room_1 = &rooms[0];
203        assert!(child_room_1.is_last_admin);
204
205        let child_room_2 = &rooms[1];
206        assert!(!child_room_2.is_last_admin);
207
208        let room_ids = rooms.iter().map(|r| r.space_room.room_id.clone()).collect::<Vec<_>>();
209        assert_eq!(room_ids, vec![child_space_id_1, child_space_id_2, parent_space_id]);
210
211        handle.leave(|room| room_ids.contains(&room.space_room.room_id)).await.unwrap();
212
213        assert!(space_service.joined_spaces().await.is_empty());
214    }
215}