Skip to content

Commit a9952f9

Browse files
committed
show room search filter by default; fix room hierarchy ordering
1 parent 81e519a commit a9952f9

5 files changed

Lines changed: 176 additions & 6 deletions

File tree

REUSE.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2180,6 +2180,11 @@ SPDX-FileCopyrightText = [
21802180
]
21812181
SPDX-License-Identifier = "Apache-2.0"
21822182

2183+
[[annotations]]
2184+
path = ["src/components/rooms/RoomHierarchy.test.ts"]
2185+
SPDX-FileCopyrightText = ["2026 Nikita Chernyi <https://etke.cc>"]
2186+
SPDX-License-Identifier = "Apache-2.0"
2187+
21832188
[[annotations]]
21842189
path = ["src/components/rooms/EventLookupDialog.tsx"]
21852190
SPDX-FileCopyrightText = [
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { HierarchyRoom } from "../../providers/types";
4+
import { buildTree, isValidOrder, sortChildren } from "./RoomHierarchy";
5+
6+
const makeChild = (
7+
state_key: string,
8+
origin_server_ts: number,
9+
order?: string
10+
): HierarchyRoom["children_state"][number] => ({
11+
type: "m.space.child",
12+
state_key,
13+
content: { via: ["example.org"], ...(order !== undefined ? { order } : {}) },
14+
sender: "@admin:example.org",
15+
origin_server_ts,
16+
});
17+
18+
const makeRoom = (room_id: string, children: HierarchyRoom["children_state"] = []): HierarchyRoom => ({
19+
room_id,
20+
num_joined_members: 1,
21+
guest_can_join: false,
22+
world_readable: false,
23+
children_state: children,
24+
});
25+
26+
describe("isValidOrder", () => {
27+
it("accepts printable ASCII strings", () => {
28+
expect(isValidOrder("1")).toBe(true);
29+
expect(isValidOrder("abc")).toBe(true);
30+
expect(isValidOrder(" ")).toBe(true); // 0x20
31+
expect(isValidOrder("~")).toBe(true); // 0x7E
32+
});
33+
34+
it("rejects empty string", () => {
35+
expect(isValidOrder("")).toBe(false);
36+
});
37+
38+
it("rejects non-strings", () => {
39+
expect(isValidOrder(undefined)).toBe(false);
40+
expect(isValidOrder(null)).toBe(false);
41+
expect(isValidOrder(1)).toBe(false);
42+
});
43+
44+
it("rejects strings with control characters", () => {
45+
expect(isValidOrder("\x1F")).toBe(false);
46+
expect(isValidOrder("\x7F")).toBe(false);
47+
});
48+
49+
it("rejects non-ASCII Unicode that would pass naive string comparison", () => {
50+
expect(isValidOrder("\u0100")).toBe(false); // Ā — codepoint 256, above 0x7E
51+
expect(isValidOrder("café")).toBe(false); // é is codepoint 233, above 0x7E
52+
});
53+
});
54+
55+
describe("sortChildren", () => {
56+
it("sorts lexicographically by order field", () => {
57+
const children = [makeChild("!c", 1, "3"), makeChild("!a", 2, "1"), makeChild("!b", 3, "2")];
58+
const sorted = sortChildren(children).map(c => c.state_key);
59+
expect(sorted).toEqual(["!a", "!b", "!c"]);
60+
});
61+
62+
it("sorts without order by origin_server_ts ascending", () => {
63+
const children = [makeChild("!c", 300), makeChild("!a", 100), makeChild("!b", 200)];
64+
const sorted = sortChildren(children).map(c => c.state_key);
65+
expect(sorted).toEqual(["!a", "!b", "!c"]);
66+
});
67+
68+
it("places ordered children before unordered", () => {
69+
const children = [makeChild("!unordered", 1), makeChild("!ordered", 999, "1")];
70+
const sorted = sortChildren(children).map(c => c.state_key);
71+
expect(sorted).toEqual(["!ordered", "!unordered"]);
72+
});
73+
74+
it("treats empty string order as no order", () => {
75+
const children = [makeChild("!empty-order", 1, ""), makeChild("!ordered", 999, "1")];
76+
const sorted = sortChildren(children).map(c => c.state_key);
77+
expect(sorted).toEqual(["!ordered", "!empty-order"]);
78+
});
79+
80+
it("falls back to origin_server_ts then state_key when order values are equal", () => {
81+
const children = [makeChild("!z", 2, "1"), makeChild("!a", 1, "1")];
82+
const sorted = sortChildren(children).map(c => c.state_key);
83+
expect(sorted).toEqual(["!a", "!z"]);
84+
});
85+
86+
it("uses state_key as final tiebreaker when origin_server_ts equal and no order", () => {
87+
const children = [makeChild("!z", 100), makeChild("!a", 100)];
88+
const sorted = sortChildren(children).map(c => c.state_key);
89+
expect(sorted).toEqual(["!a", "!z"]);
90+
});
91+
92+
it("does not mutate the original array", () => {
93+
const children = [makeChild("!b", 1, "2"), makeChild("!a", 2, "1")];
94+
const original = [...children];
95+
sortChildren(children);
96+
expect(children).toEqual(original);
97+
});
98+
});
99+
100+
describe("buildTree", () => {
101+
it("returns empty array for empty input", () => {
102+
expect(buildTree([])).toEqual([]);
103+
});
104+
105+
it("respects order field when building children", () => {
106+
const root = makeRoom("!root", [makeChild("!c", 1, "3"), makeChild("!a", 2, "1"), makeChild("!b", 3, "2")]);
107+
const roomA = makeRoom("!a");
108+
const roomB = makeRoom("!b");
109+
const roomC = makeRoom("!c");
110+
const [rootNode] = buildTree([root, roomC, roomA, roomB]);
111+
expect(rootNode.children.map(n => n.room.room_id)).toEqual(["!a", "!b", "!c"]);
112+
});
113+
114+
it("handles cycle detection correctly after sort", () => {
115+
const roomA = makeRoom("!a", [makeChild("!b", 1)]);
116+
const roomB = makeRoom("!b", [makeChild("!a", 2)]);
117+
const [rootNode] = buildTree([roomA, roomB]);
118+
expect(rootNode.children).toHaveLength(1);
119+
expect(rootNode.children[0].children).toHaveLength(0);
120+
});
121+
122+
it("appends orphan rooms after root", () => {
123+
const root = makeRoom("!root");
124+
const orphan = makeRoom("!orphan");
125+
const nodes = buildTree([root, orphan]);
126+
expect(nodes).toHaveLength(2);
127+
expect(nodes[0].room.room_id).toBe("!root");
128+
expect(nodes[1].room.room_id).toBe("!orphan");
129+
});
130+
});

src/components/rooms/RoomHierarchy.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,37 @@ interface TreeNode {
3535

3636
let nodeKeyCounter = 0;
3737

38+
// Per MSC2946: a valid order value is a non-empty string of printable ASCII characters (0x20–0x7E).
39+
// Spread iterates Unicode code points so multi-byte characters are caught correctly.
40+
export const isValidOrder = (o: unknown): o is string =>
41+
typeof o === "string" &&
42+
o.length > 0 &&
43+
[...o].every(c => {
44+
const cp = c.codePointAt(0) ?? 0;
45+
return cp >= 0x20 && cp <= 0x7e;
46+
});
47+
48+
// Sorts m.space.child events per the Matrix spec (MSC2946):
49+
// 1. Children with a valid `order` string sort first, lexicographically.
50+
// 2. Children without a valid `order` sort after, by origin_server_ts ascending.
51+
// 3. Equal order strings, or equal timestamps, fall back to state_key for a stable result.
52+
export const sortChildren = <T extends { state_key: string; content: { order?: string }; origin_server_ts: number }>(
53+
children: T[]
54+
): T[] =>
55+
[...children].sort((a, b) => {
56+
const aOrd = isValidOrder(a.content.order) ? a.content.order : undefined;
57+
const bOrd = isValidOrder(b.content.order) ? b.content.order : undefined;
58+
if (aOrd !== undefined && bOrd !== undefined) {
59+
if (aOrd !== bOrd) return aOrd < bOrd ? -1 : 1;
60+
// equal order strings → fall through to ts/state_key tiebreaker below
61+
} else if (aOrd !== undefined) {
62+
return -1;
63+
} else if (bOrd !== undefined) {
64+
return 1;
65+
}
66+
return a.origin_server_ts - b.origin_server_ts || a.state_key.localeCompare(b.state_key);
67+
});
68+
3869
const collectChildIds = (rooms: HierarchyRoom[]): Set<string> => {
3970
const knownIds = new Set(rooms.map(r => r.room_id));
4071
const missing = new Set<string>();
@@ -48,7 +79,7 @@ const collectChildIds = (rooms: HierarchyRoom[]): Set<string> => {
4879
return missing;
4980
};
5081

51-
const buildTree = (rooms: HierarchyRoom[]): TreeNode[] => {
82+
export const buildTree = (rooms: HierarchyRoom[]): TreeNode[] => {
5283
if (rooms.length === 0) return [];
5384
nodeKeyCounter = 0;
5485

@@ -63,7 +94,7 @@ const buildTree = (rooms: HierarchyRoom[]): TreeNode[] => {
6394
const key = `${room.room_id}-${nodeKeyCounter++}`;
6495
const children: TreeNode[] = [];
6596
if (room.children_state) {
66-
for (const child of room.children_state) {
97+
for (const child of sortChildren(room.children_state)) {
6798
if (visited.has(child.state_key)) continue;
6899
visited.add(child.state_key);
69100
const knownRoom = roomMap.get(child.state_key);

src/providers/types/rooms.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ export interface HierarchyRoom {
8787
children_state: {
8888
type: string;
8989
state_key: string;
90-
content: Record<string, unknown>;
90+
content: {
91+
via?: string[];
92+
suggested?: boolean;
93+
order?: string;
94+
[key: string]: unknown;
95+
};
9196
sender: string;
9297
origin_server_ts: number;
9398
}[];

src/resources/rooms/List.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,19 +261,18 @@ export const RoomBulkActionButtons = () => {
261261
);
262262
};
263263

264-
const RoomSearchInput = () => {
264+
const RoomSearchInput = (_props: { alwaysOn?: boolean }) => {
265265
const translate = useTranslate();
266266
return (
267267
<SearchInput
268268
source="search_term"
269-
alwaysOn
270269
slotProps={{ htmlInput: { "aria-label": translate("ra.action.search") } }}
271270
/>
272271
);
273272
};
274273

275274
const roomFilters = [
276-
<RoomSearchInput key="search_term" />,
275+
<RoomSearchInput key="search_term" alwaysOn />,
277276
<NullableBooleanInput key="public_rooms" source="public_rooms" label="resources.rooms.filter.public_rooms" />,
278277
<NullableBooleanInput key="empty_rooms" source="empty_rooms" label="resources.rooms.filter.empty_rooms" />,
279278
];

0 commit comments

Comments
 (0)