Skip to content

Commit 5b9a2fe

Browse files
committed
chore(spaces): build a graph from joined spaces parent and child relations to correctly detect all the edges and be able to remove any cycles.
- cycle removing is done through DFS and keeping a list of visited nodes
1 parent a942e24 commit 5b9a2fe

File tree

2 files changed

+263
-18
lines changed

2 files changed

+263
-18
lines changed
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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+
15+
use std::collections::{BTreeMap, BTreeSet};
16+
17+
use ruma::OwnedRoomId;
18+
19+
#[derive(Debug)]
20+
struct SpaceServiceGraphNode {
21+
id: OwnedRoomId,
22+
parents: BTreeSet<OwnedRoomId>,
23+
children: BTreeSet<OwnedRoomId>,
24+
}
25+
26+
impl SpaceServiceGraphNode {
27+
fn new(id: OwnedRoomId) -> Self {
28+
Self { id, parents: BTreeSet::new(), children: BTreeSet::new() }
29+
}
30+
}
31+
32+
#[derive(Debug)]
33+
pub struct SpaceServiceGraph {
34+
nodes: BTreeMap<OwnedRoomId, SpaceServiceGraphNode>,
35+
}
36+
37+
impl Default for SpaceServiceGraph {
38+
fn default() -> Self {
39+
Self::new()
40+
}
41+
}
42+
43+
impl SpaceServiceGraph {
44+
pub fn new() -> Self {
45+
Self { nodes: BTreeMap::new() }
46+
}
47+
48+
pub fn root_nodes(&self) -> Vec<&OwnedRoomId> {
49+
self.nodes.values().filter(|node| node.parents.is_empty()).map(|node| &node.id).collect()
50+
}
51+
52+
pub fn add_node(&mut self, node_id: OwnedRoomId) {
53+
self.nodes.entry(node_id.clone()).or_insert(SpaceServiceGraphNode::new(node_id));
54+
}
55+
56+
pub fn add_edge(&mut self, parent_id: OwnedRoomId, child_id: OwnedRoomId) {
57+
self.nodes
58+
.entry(parent_id.clone())
59+
.or_insert(SpaceServiceGraphNode::new(parent_id.clone()));
60+
61+
self.nodes.entry(child_id.clone()).or_insert(SpaceServiceGraphNode::new(child_id.clone()));
62+
63+
self.nodes.get_mut(&parent_id).unwrap().children.insert(child_id.clone());
64+
self.nodes.get_mut(&child_id).unwrap().parents.insert(parent_id);
65+
}
66+
67+
pub fn remove_cycles(&mut self) {
68+
let mut visited = BTreeSet::new();
69+
let mut stack = BTreeSet::new();
70+
71+
let mut edges_to_remove = Vec::new();
72+
73+
for node_id in self.nodes.keys().cloned().collect::<Vec<_>>() {
74+
self.dfs_remove_cycles(&node_id, &mut visited, &mut stack, &mut edges_to_remove);
75+
}
76+
77+
for (parent, child) in edges_to_remove {
78+
if let Some(node) = self.nodes.get_mut(&parent) {
79+
node.children.remove(&child);
80+
}
81+
if let Some(node) = self.nodes.get_mut(&child) {
82+
node.parents.remove(&parent);
83+
}
84+
}
85+
}
86+
87+
fn dfs_remove_cycles(
88+
&self,
89+
node_id: &OwnedRoomId,
90+
visited: &mut BTreeSet<OwnedRoomId>,
91+
stack: &mut BTreeSet<OwnedRoomId>,
92+
edges_to_remove: &mut Vec<(OwnedRoomId, OwnedRoomId)>,
93+
) {
94+
if !visited.insert(node_id.clone()) {
95+
return;
96+
}
97+
98+
stack.insert(node_id.clone());
99+
100+
if let Some(node) = self.nodes.get(node_id) {
101+
for child in &node.children {
102+
if stack.contains(child) {
103+
// Found a cycle → mark this edge for removal
104+
edges_to_remove.push((node_id.clone(), child.clone()));
105+
} else {
106+
self.dfs_remove_cycles(child, visited, stack, edges_to_remove);
107+
}
108+
}
109+
}
110+
111+
stack.remove(node_id);
112+
}
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use ruma::room_id;
118+
119+
use super::*;
120+
121+
#[test]
122+
fn test_add_edge_and_root_nodes() {
123+
let mut graph = SpaceServiceGraph::new();
124+
125+
let a = room_id!("!a:example.org").to_owned();
126+
let b = room_id!("!b:example.org").to_owned();
127+
let c = room_id!("!c:example.org").to_owned();
128+
129+
graph.add_edge(a.clone(), b.clone());
130+
graph.add_edge(a.clone(), c.clone());
131+
132+
assert_eq!(graph.root_nodes(), vec![&a]);
133+
134+
assert!(graph.nodes[&b].parents.contains(&a));
135+
assert!(graph.nodes[&c].parents.contains(&a));
136+
}
137+
138+
#[test]
139+
fn test_remove_cycles() {
140+
let mut graph = SpaceServiceGraph::new();
141+
142+
let a = room_id!("!a:example.org").to_owned();
143+
let b = room_id!("!b:example.org").to_owned();
144+
let c = room_id!("!c:example.org").to_owned();
145+
146+
graph.add_edge(a.clone(), b.clone());
147+
graph.add_edge(b, c.clone());
148+
graph.add_edge(c.clone(), a.clone()); // creates a cycle
149+
150+
assert!(graph.nodes[&c].children.contains(&a));
151+
152+
graph.remove_cycles();
153+
154+
assert!(!graph.nodes[&c].children.contains(&a));
155+
assert!(!graph.nodes[&a].parents.contains(&c));
156+
}
157+
158+
#[test]
159+
fn test_disconnected_graph_roots() {
160+
let mut graph = SpaceServiceGraph::new();
161+
162+
let a = room_id!("!a:example.org").to_owned();
163+
let b = room_id!("!b:example.org").to_owned();
164+
graph.add_edge(a.clone(), b);
165+
166+
let x = room_id!("!x:example.org").to_owned();
167+
let y = room_id!("!y:example.org").to_owned();
168+
graph.add_edge(x.clone(), y);
169+
170+
let mut roots = graph.root_nodes();
171+
roots.sort_by_key(|key| key.to_string());
172+
173+
let expected: Vec<&OwnedRoomId> = vec![&a, &x];
174+
assert_eq!(roots, expected);
175+
}
176+
177+
#[test]
178+
fn test_multiple_parents() {
179+
let mut graph = SpaceServiceGraph::new();
180+
181+
let a = room_id!("!a:example.org").to_owned();
182+
let b = room_id!("!b:example.org").to_owned();
183+
let c = room_id!("!c:example.org").to_owned();
184+
let d = room_id!("!d:example.org").to_owned();
185+
186+
graph.add_edge(a.clone(), c.clone());
187+
graph.add_edge(b.clone(), c.clone());
188+
graph.add_edge(c.clone(), d);
189+
190+
let mut roots = graph.root_nodes();
191+
roots.sort_by_key(|key| key.to_string());
192+
193+
let expected = vec![&a, &b];
194+
assert_eq!(roots, expected);
195+
196+
let c_parents = &graph.nodes[&c].parents;
197+
assert!(c_parents.contains(&a));
198+
assert!(c_parents.contains(&b));
199+
}
200+
}

crates/matrix-sdk-ui/src/spaces/mod.rs

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,21 @@
1818
1919
use eyeball::{SharedObservable, Subscriber};
2020
use futures_util::pin_mut;
21-
use matrix_sdk::Client;
21+
use matrix_sdk::{Client, deserialized_responses::SyncOrStrippedState};
2222
use matrix_sdk_common::executor::{JoinHandle, spawn};
23-
use ruma::OwnedRoomId;
24-
use tokio_stream::StreamExt;
23+
use ruma::{
24+
OwnedRoomId,
25+
events::{
26+
SyncStateEvent,
27+
space::{child::SpaceChildEventContent, parent::SpaceParentEventContent},
28+
},
29+
};
2530
use tracing::error;
2631

32+
use crate::spaces::graph::SpaceServiceGraph;
2733
pub use crate::spaces::{room::SpaceServiceRoom, room_list::SpaceServiceRoomList};
2834

35+
pub mod graph;
2936
pub mod room;
3037
pub mod room_list;
3138

@@ -90,25 +97,63 @@ impl SpaceService {
9097
let joined_spaces = client
9198
.joined_rooms()
9299
.into_iter()
93-
.filter_map(|room| if room.is_space() { Some(room) } else { None })
100+
.filter_map(|room| room.is_space().then_some(room))
94101
.collect::<Vec<_>>();
95102

96-
let top_level_spaces: Vec<SpaceServiceRoom> = tokio_stream::iter(joined_spaces)
97-
.then(|room| async move {
98-
let ok = if let Ok(parents) = room.parent_spaces().await {
99-
pin_mut!(parents);
100-
!parents.any(|p| p.is_ok()).await
103+
let mut graph = SpaceServiceGraph::new();
104+
105+
for space in joined_spaces.iter() {
106+
graph.add_node(space.room_id().to_owned());
107+
108+
if let Ok(parents) = space.get_state_events_static::<SpaceParentEventContent>().await {
109+
parents.into_iter()
110+
.flat_map(|parent_event| match parent_event.deserialize() {
111+
Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(e))) => {
112+
Some(e.state_key)
113+
}
114+
Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => None,
115+
Ok(SyncOrStrippedState::Stripped(e)) => Some(e.state_key),
116+
Err(e) => {
117+
error!(room_id = ?space.room_id(), "Could not deserialize m.space.parent: {e}");
118+
None
119+
}
120+
}).for_each(|parent| graph.add_edge(parent, space.room_id().to_owned()));
121+
} else {
122+
error!(room_id = ?space.room_id(), "Could not get m.space.parent events");
123+
}
124+
125+
if let Ok(children) = space.get_state_events_static::<SpaceChildEventContent>().await {
126+
children.into_iter()
127+
.flat_map(|child_event| match child_event.deserialize() {
128+
Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(e))) => {
129+
Some(e.state_key)
130+
}
131+
Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => None,
132+
Ok(SyncOrStrippedState::Stripped(e)) => Some(e.state_key),
133+
Err(e) => {
134+
error!(room_id = ?space.room_id(), "Could not deserialize m.space.child: {e}");
135+
None
136+
}
137+
}).for_each(|child| graph.add_edge(space.room_id().to_owned(), child));
138+
} else {
139+
error!(room_id = ?space.room_id(), "Could not get m.space.child events");
140+
}
141+
}
142+
143+
graph.remove_cycles();
144+
145+
let root_notes = graph.root_nodes();
146+
147+
joined_spaces
148+
.iter()
149+
.flat_map(|room| {
150+
if root_notes.contains(&&room.room_id().to_owned()) {
151+
Some(SpaceServiceRoom::new_from_known(room.clone()))
101152
} else {
102-
false
103-
};
104-
(room, ok)
153+
None
154+
}
105155
})
106-
.filter(|(_, ok)| *ok)
107-
.map(|(x, _)| SpaceServiceRoom::new_from_known(x))
108-
.collect()
109-
.await;
110-
111-
top_level_spaces
156+
.collect::<Vec<_>>()
112157
}
113158
}
114159

0 commit comments

Comments
 (0)