diff --git a/apps/mobile/lib/screens/home_screen.dart b/apps/mobile/lib/screens/home_screen.dart index 54a23f3..0928e6e 100644 --- a/apps/mobile/lib/screens/home_screen.dart +++ b/apps/mobile/lib/screens/home_screen.dart @@ -10,6 +10,7 @@ import '../widgets/avatar.dart'; import '../widgets/new_chat_sheet.dart'; import 'encryption_screen.dart'; import 'room_screen.dart'; +import 'space_screen.dart'; import 'settings_screen.dart'; enum _Filter { all, unread, people, rooms } @@ -32,8 +33,9 @@ class _HomeScreenState extends ConsumerState { super.dispose(); } - bool _matches(Room room) { - if (room.isSpace) return false; + bool _matches(Room room, Set? inSpace) { + if (room.isSpace && room.membership != Membership.invite) return false; + if (inSpace != null && !inSpace.contains(room.id)) return false; final query = _search.text.trim().toLowerCase(); if (query.isNotEmpty && !room.getLocalizedDisplayname().toLowerCase().contains(query) && @@ -48,12 +50,67 @@ class _HomeScreenState extends ConsumerState { }; } + Future _pickSpace(Client client) async { + final spaces = client.rooms + .where((r) => r.isSpace && r.membership == Membership.join) + .toList(); + final current = ref.read(selectedSpaceProvider); + // '' stands for "all chats"; null is a dismissed sheet. + final picked = await showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Image.asset( + 'assets/logo/warp-symbol.png', + width: 40, + height: 40, + ), + title: const Text('All chats'), + selected: current == null, + onTap: () => Navigator.pop(sheet, ''), + ), + for (final s in spaces) + ListTile( + leading: Avatar( + name: s.getLocalizedDisplayname(), + seed: s.id, + mxc: s.avatar, + ), + title: Text(s.getLocalizedDisplayname()), + selected: current == s.id, + onTap: () => Navigator.pop(sheet, s.id), + ), + if (spaces.isEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(24, 8, 24, 16), + child: Text( + "You aren't in any spaces. Create one from the new chat " + 'button, or join one by its address.', + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (picked == null) return; + ref + .read(selectedSpaceProvider.notifier) + .select(picked.isEmpty ? null : picked); + } + @override Widget build(BuildContext context) { ref.watch(syncProvider); final client = ref.watch(clientProvider); final offline = ref.watch(syncErrorProvider).value == true; - final rooms = client.rooms.where(_matches).toList(); + final spaceId = ref.watch(selectedSpaceProvider); + final space = spaceId == null ? null : client.getRoomById(spaceId); + final inSpace = space == null ? null : roomsInSpace(space); + final rooms = client.rooms.where((r) => _matches(r, inSpace)).toList(); final invites = rooms .where((r) => r.membership == Membership.invite) .toList(); @@ -72,16 +129,33 @@ class _HomeScreenState extends ConsumerState { isDense: true, ), ) - : Row( - children: [ - Image.asset( - 'assets/logo/warp-symbol.png', - width: 28, - height: 28, - ), - const SizedBox(width: 10), - const Text('Chats'), - ], + : InkWell( + onTap: () => _pickSpace(client), + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + space == null + ? Image.asset( + 'assets/logo/warp-symbol.png', + width: 28, + height: 28, + ) + : Avatar( + name: space.getLocalizedDisplayname(), + seed: space.id, + mxc: space.avatar, + size: 28, + ), + const SizedBox(width: 10), + Flexible( + child: Text( + space?.getLocalizedDisplayname() ?? 'Chats', + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), ), actions: [ IconButton( @@ -142,7 +216,7 @@ class _HomeScreenState extends ConsumerState { ), ), Expanded( - child: rooms.isEmpty + child: rooms.isEmpty && space == null ? _Empty( loading: client.prevBatch == null, filtered: _filter != _Filter.all || _search.text.isNotEmpty, @@ -155,6 +229,19 @@ class _HomeScreenState extends ConsumerState { for (final room in invites) _InviteTile(room: room), if (joined.isNotEmpty) const _SectionHeader('Chats'), ], + if (space != null) + ListTile( + leading: const Icon(Icons.travel_explore), + title: Text( + 'Browse ${space.getLocalizedDisplayname()}', + ), + subtitle: const Text('Rooms you can join'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpaceScreen(spaceId: space.id), + ), + ), + ), for (final room in joined) RoomTile(room: room), ], ), @@ -165,6 +252,23 @@ class _HomeScreenState extends ConsumerState { } } +/// The rooms in [space]: its children, and the children of the spaces in +/// it, one level down. +Set roomsInSpace(Room space) { + final client = space.client; + final ids = {}; + for (final child in space.spaceChildren) { + final id = child.roomId; + if (id == null) continue; + ids.add(id); + final sub = client.getRoomById(id); + if (sub != null && sub.isSpace && sub.membership == Membership.join) { + ids.addAll(sub.spaceChildren.map((c) => c.roomId).whereType()); + } + } + return ids; +} + class _OwnAvatar extends StatelessWidget { const _OwnAvatar({required this.client}); final Client client; @@ -540,9 +644,14 @@ class _InviteTileState extends ConsumerState<_InviteTile> { icon: const Icon(Icons.check), onPressed: () => _act(() async { await room.join(); - if (context.mounted) { - Navigator.of(context).push(RoomScreen.route(room.id)); - } + if (!context.mounted) return; + Navigator.of(context).push( + room.isSpace + ? MaterialPageRoute( + builder: (_) => SpaceScreen(spaceId: room.id), + ) + : RoomScreen.route(room.id), + ); }), ), ], diff --git a/apps/mobile/lib/screens/room_info_screen.dart b/apps/mobile/lib/screens/room_info_screen.dart index 0b08d76..8400c53 100644 --- a/apps/mobile/lib/screens/room_info_screen.dart +++ b/apps/mobile/lib/screens/room_info_screen.dart @@ -69,7 +69,9 @@ class _RoomInfoScreenState extends ConsumerState { if (!id.contains(':')) id = '$id:${room.client.userID!.domain}'; try { await room.invite(id); - setState(() => _members = room.requestParticipants()); + setState(() { + _members = room.requestParticipants(); + }); if (mounted) { ScaffoldMessenger.of( context, diff --git a/apps/mobile/lib/screens/sessions_screen.dart b/apps/mobile/lib/screens/sessions_screen.dart index 0c5871b..a30b97a 100644 --- a/apps/mobile/lib/screens/sessions_screen.dart +++ b/apps/mobile/lib/screens/sessions_screen.dart @@ -26,8 +26,9 @@ class _SessionsScreenState extends ConsumerState { _devices = ref.read(clientProvider).getDevices(); } - void _reload() => - setState(() => _devices = ref.read(clientProvider).getDevices()); + void _reload() => setState(() { + _devices = ref.read(clientProvider).getDevices(); + }); void _snack(Object e) { if (!mounted) return; diff --git a/apps/mobile/lib/screens/settings_screen.dart b/apps/mobile/lib/screens/settings_screen.dart index 88f64d8..d79d6bb 100644 --- a/apps/mobile/lib/screens/settings_screen.dart +++ b/apps/mobile/lib/screens/settings_screen.dart @@ -50,12 +50,12 @@ class _SettingsScreenState extends ConsumerState { void _reloadProfile() { final client = ref.read(clientProvider); - setState( - () => _profile = client.getProfileFromUserId( + setState(() { + _profile = client.getProfileFromUserId( client.userID!, maxCacheAge: Duration.zero, - ), - ); + ); + }); } Future _editName(String current) async { @@ -111,7 +111,9 @@ class _SettingsScreenState extends ConsumerState { Future _togglePush(bool on) async { if (!on) { await _run(Push.disable); - setState(() => _distributor = Push.currentDistributor()); + setState(() { + _distributor = Push.currentDistributor(); + }); return; } await Notifications.requestPermission(); @@ -170,7 +172,9 @@ class _SettingsScreenState extends ConsumerState { await _run(() async { await Push.enable(distributor: distributor); }); - setState(() => _distributor = Push.currentDistributor()); + setState(() { + _distributor = Push.currentDistributor(); + }); } Future _signOut() async { diff --git a/apps/mobile/lib/screens/space_screen.dart b/apps/mobile/lib/screens/space_screen.dart new file mode 100644 index 0000000..3bae1ed --- /dev/null +++ b/apps/mobile/lib/screens/space_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart' hide Visibility; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; + +import '../matrix/client.dart'; +import '../state.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import 'room_screen.dart'; + +/// The rooms of a space, the user's own and the ones they could join +/// (`/hierarchy`): join them, open them, and for those who may, add rooms. +class SpaceScreen extends ConsumerStatefulWidget { + const SpaceScreen({super.key, required this.spaceId}); + final String spaceId; + + @override + ConsumerState createState() => _SpaceScreenState(); +} + +class _SpaceScreenState extends ConsumerState { + late Future> _rooms = _load(); + final _joining = {}; + + Future> _load() async { + final client = ref.read(clientProvider); + // The topic and other non-essential state are loaded on demand. + await client.getRoomById(widget.spaceId)?.postLoad(); + final rooms = []; + String? from; + // A few pages at most: a space with hundreds of rooms is browsed by + // scrolling the first ones, not by fetching everything up front. + for (var page = 0; page < 5; page++) { + final GetSpaceHierarchyResponse response; + try { + response = await client.getSpaceHierarchy( + widget.spaceId, + maxDepth: 2, + limit: 50, + from: from, + ); + } catch (e, s) { + Logs().e('Could not load the space hierarchy', e, s); + rethrow; + } + rooms.addAll(response.rooms.where((r) => r.roomId != widget.spaceId)); + from = response.nextBatch; + if (from == null) break; + } + return rooms; + } + + void _reload() => setState(() { + _rooms = _load(); + }); + + void _snack(Object e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(describeError(e)))); + } + + Future _join(SpaceRoomsChunk$2 chunk, Room space) async { + final client = ref.read(clientProvider); + setState(() => _joining.add(chunk.roomId)); + try { + final via = space.spaceChildren + .where((c) => c.roomId == chunk.roomId) + .firstOrNull + ?.via; + await client.joinRoom(chunk.roomId, via: via); + if (client.getRoomById(chunk.roomId)?.membership != Membership.join) { + await client.waitForRoomInSync(chunk.roomId, join: true); + } + _reload(); + } catch (e) { + _snack(e); + } finally { + if (mounted) setState(() => _joining.remove(chunk.roomId)); + } + } + + Future _addExisting(Room space) async { + final client = ref.read(clientProvider); + final inSpace = space.spaceChildren.map((c) => c.roomId).toSet(); + final candidates = client.rooms + .where( + (r) => + r.membership == Membership.join && + r.id != space.id && + !inSpace.contains(r.id), + ) + .toList(); + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheet) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + builder: (context, scroll) => ListView( + controller: scroll, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text('Add a room to this space'), + ), + if (candidates.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('All your rooms are already in it.'), + ), + for (final r in candidates) + ListTile( + leading: Avatar( + name: r.getLocalizedDisplayname(), + seed: r.id, + mxc: r.avatar, + ), + title: Text(r.getLocalizedDisplayname()), + subtitle: r.isSpace ? const Text('Space') : null, + onTap: () => Navigator.pop(sheet, r), + ), + ], + ), + ), + ); + if (picked == null) return; + try { + await space.setSpaceChild(picked.id); + _reload(); + } catch (e) { + _snack(e); + } + } + + Future _createInSpace(Room space) async { + final client = ref.read(clientProvider); + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('New room in this space'), + content: TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (v) => Navigator.pop(dialog, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, controller.text), + child: const Text('Create'), + ), + ], + ), + ); + if (name == null || name.trim().isEmpty) return; + try { + final roomId = await client.createGroupChat( + groupName: name.trim(), + enableEncryption: client.encryptionEnabled, + preset: CreateRoomPreset.privateChat, + ); + await space.setSpaceChild(roomId); + _reload(); + } catch (e) { + _snack(e); + } + } + + @override + Widget build(BuildContext context) { + ref.watch(syncProvider); + final client = ref.watch(clientProvider); + final space = client.getRoomById(widget.spaceId); + if (space == null) return Scaffold(appBar: AppBar()); + final canAdd = space.canChangeStateEvent(EventTypes.SpaceChild); + final text = Theme.of(context).textTheme; + final c = context.warp; + + return Scaffold( + appBar: AppBar( + title: Text(space.getLocalizedDisplayname()), + actions: [ + if (canAdd) + PopupMenuButton( + icon: const Icon(Icons.add), + tooltip: 'Add a room', + onSelected: (v) => + v == 'new' ? _createInSpace(space) : _addExisting(space), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'new', child: Text('New room')), + PopupMenuItem(value: 'existing', child: Text('Existing room')), + ], + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + _reload(); + await _rooms; + }, + child: FutureBuilder>( + future: _rooms, + builder: (context, snapshot) { + final rooms = snapshot.data; + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + Avatar( + name: space.getLocalizedDisplayname(), + seed: space.id, + mxc: space.avatar, + size: 56, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + space.getLocalizedDisplayname(), + style: text.titleLarge, + ), + if (space.topic.isNotEmpty) + Text(space.topic, style: text.bodySmall), + ], + ), + ), + ], + ), + ), + const Divider(), + if (snapshot.hasError) + Padding( + padding: const EdgeInsets.all(24), + child: Text(describeError(snapshot.error!)), + ) + else if (rooms == null) + const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ) + else if (rooms.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Text( + canAdd + ? 'No rooms yet. Add one with +.' + : 'No rooms in this space yet.', + textAlign: TextAlign.center, + ), + ) + else + for (final r in rooms) + Builder( + builder: (context) { + final local = client.getRoomById(r.roomId); + final joined = local?.membership == Membership.join; + final isSpace = r.roomType == 'm.space'; + final name = r.name ?? r.canonicalAlias ?? r.roomId; + return ListTile( + leading: Avatar( + name: name, + seed: r.roomId, + mxc: r.avatarUrl, + ), + title: Text(name), + subtitle: Text( + [ + if (isSpace) 'Space', + '${r.numJoinedMembers} ' + '${r.numJoinedMembers == 1 ? 'member' : 'members'}', + if (r.topic != null && r.topic!.isNotEmpty) + r.topic!, + ].join(' ยท '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: joined + ? Text( + 'Joined', + style: text.labelMedium?.copyWith( + color: c.tealText, + ), + ) + : _joining.contains(r.roomId) + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : FilledButton( + onPressed: () => _join(r, space), + child: const Text('Join'), + ), + onTap: !joined + ? null + : isSpace + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + SpaceScreen(spaceId: r.roomId), + ), + ) + : () => Navigator.of( + context, + ).push(RoomScreen.route(r.roomId)), + ); + }, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/screens/thread_list_screen.dart b/apps/mobile/lib/screens/thread_list_screen.dart index 991b403..fd8642f 100644 --- a/apps/mobile/lib/screens/thread_list_screen.dart +++ b/apps/mobile/lib/screens/thread_list_screen.dart @@ -45,7 +45,9 @@ class _ThreadListScreenState extends ConsumerState { appBar: AppBar(title: const Text('Threads')), body: RefreshIndicator( onRefresh: () async { - setState(() => _threads = _load()); + setState(() { + _threads = _load(); + }); await _threads; }, child: FutureBuilder>( diff --git a/apps/mobile/lib/state.dart b/apps/mobile/lib/state.dart index 3553767..8cd8a9e 100644 --- a/apps/mobile/lib/state.dart +++ b/apps/mobile/lib/state.dart @@ -60,3 +60,15 @@ class ThemeModeNotifier extends Notifier { final themeModeProvider = NotifierProvider( ThemeModeNotifier.new, ); + +/// The space the room list shows, or null for all chats. +class SelectedSpaceNotifier extends Notifier { + @override + String? build() => null; + + void select(String? spaceId) => state = spaceId; +} + +final selectedSpaceProvider = NotifierProvider( + SelectedSpaceNotifier.new, +); diff --git a/apps/mobile/lib/widgets/new_chat_sheet.dart b/apps/mobile/lib/widgets/new_chat_sheet.dart index 6b7e6c8..5d47a51 100644 --- a/apps/mobile/lib/widgets/new_chat_sheet.dart +++ b/apps/mobile/lib/widgets/new_chat_sheet.dart @@ -4,6 +4,7 @@ import 'package:matrix/matrix.dart'; import '../matrix/client.dart'; import '../screens/room_screen.dart'; +import '../screens/space_screen.dart'; import '../state.dart'; /// Start a direct chat, create a room or join one. @@ -32,6 +33,15 @@ Future showNewChatSheet(BuildContext context) => _open(context, const _CreateRoomDialog()); }, ), + ListTile( + leading: const Icon(Icons.workspaces_outline), + title: const Text('New space'), + subtitle: const Text('A group of rooms'), + onTap: () { + Navigator.pop(sheet); + _open(context, const _CreateSpaceDialog()); + }, + ), ListTile( leading: const Icon(Icons.login), title: const Text('Join a room'), @@ -52,7 +62,17 @@ Future _open(BuildContext context, Widget dialog) async { context: context, builder: (_) => dialog, ); - if (roomId != null && context.mounted) { + if (roomId == null || !context.mounted) return; + final container = ProviderScope.containerOf(context, listen: false); + final room = container.read(clientProvider).getRoomById(roomId); + if (room != null && room.isSpace) { + // A space opens as the room list narrowed to it, with its browser on + // top. + container.read(selectedSpaceProvider.notifier).select(roomId); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => SpaceScreen(spaceId: roomId))); + } else { Navigator.of(context).push(RoomScreen.route(roomId)); } } @@ -327,3 +347,76 @@ class _JoinRoomDialogState extends ConsumerState<_JoinRoomDialog> { ); } } + +class _CreateSpaceDialog extends ConsumerStatefulWidget { + const _CreateSpaceDialog(); + + @override + ConsumerState<_CreateSpaceDialog> createState() => _CreateSpaceDialogState(); +} + +class _CreateSpaceDialogState extends ConsumerState<_CreateSpaceDialog> { + final _name = TextEditingController(); + final _topic = TextEditingController(); + bool _public = false; + + @override + void dispose() { + _name.dispose(); + _topic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final client = ref.watch(clientProvider); + return _FormDialog( + title: 'New space', + action: 'Create', + fields: [ + TextField( + controller: _name, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: _topic, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Topic (optional)'), + ), + const SizedBox(height: 8), + StatefulBuilder( + builder: (context, setLocal) => SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Public'), + subtitle: const Text('Anyone can find and join it'), + value: _public, + onChanged: (v) => setLocal(() => _public = v), + ), + ), + ], + submit: () async { + final name = _name.text.trim(); + if (name.isEmpty) throw const FormatException('Give the space a name.'); + final topic = _topic.text.trim(); + final roomId = await client.createRoom( + name: name, + topic: topic.isEmpty ? null : topic, + creationContent: {'type': RoomCreationTypes.mSpace}, + preset: _public + ? CreateRoomPreset.publicChat + : CreateRoomPreset.privateChat, + visibility: _public ? Visibility.public : Visibility.private, + // A space's own timeline isn't for chatting: only admins post. + powerLevelContentOverride: {'events_default': 100}, + ); + if (client.getRoomById(roomId)?.membership != Membership.join) { + await client.waitForRoomInSync(roomId, join: true); + } + return roomId; + }, + ); + } +} diff --git a/server/internal/clientapi/spaces.go b/server/internal/clientapi/spaces.go index 76a0551..51decd8 100644 --- a/server/internal/clientapi/spaces.go +++ b/server/internal/clientapi/spaces.go @@ -13,8 +13,18 @@ import ( // hierarchyResponse is what GET /hierarchy returns. type hierarchyResponse struct { - Rooms []roomserver.SpaceRoom `json:"rooms"` - NextBatch string `json:"next_batch,omitempty"` + Rooms []hierarchyRoom `json:"rooms"` + NextBatch string `json:"next_batch,omitempty"` +} + +// hierarchyRoom is a room as /hierarchy shows it to a client: +// children_state is required there, an empty list for a room with no +// children. SpaceRoom leaves an empty one out, as the federation response +// wants for the children it lists; this field, being shallower, takes the +// JSON name over the embedded one. +type hierarchyRoom struct { + roomserver.SpaceRoom + ChildrenState []roomserver.SpaceChildEvent `json:"children_state"` } // hierarchyTokenPrefix marks a /hierarchy token: how far into the walk @@ -62,9 +72,13 @@ func (c *ClientAPI) handleHierarchy(w http.ResponseWriter, r *http.Request, auth if err != nil { return roomError(err) } - resp := hierarchyResponse{Rooms: page.Rooms} - if resp.Rooms == nil { - resp.Rooms = []roomserver.SpaceRoom{} + resp := hierarchyResponse{Rooms: make([]hierarchyRoom, 0, len(page.Rooms))} + for _, room := range page.Rooms { + children := room.ChildrenState + if children == nil { + children = []roomserver.SpaceChildEvent{} + } + resp.Rooms = append(resp.Rooms, hierarchyRoom{SpaceRoom: room, ChildrenState: children}) } if page.Next != 0 { resp.NextBatch = hierarchyTokenPrefix + strconv.Itoa(page.Next) diff --git a/server/internal/clientapi/spaces_test.go b/server/internal/clientapi/spaces_test.go index b4e99ae..b5638d7 100644 --- a/server/internal/clientapi/spaces_test.go +++ b/server/internal/clientapi/spaces_test.go @@ -180,3 +180,32 @@ func TestRoomSummary(t *testing.T) { t.Errorf("alice's summary of her own room says membership %v", jsonBody(t, rec)["membership"]) } } + +// children_state is required on every room /hierarchy returns, an empty +// list for one with no children: clients that parse the response strictly +// (the famedly Dart SDK) fail on a room without it. +func TestSpaceHierarchyEmptyChildrenState(t *testing.T) { + ts := newTestServer(t, nil) + alice := ts.register("alice", "correct-horse-battery-staple") + space := ts.createSpace(alice, "Empty space", false) + room := ts.createRoom(alice, map[string]any{"name": "Leaf"}) + ts.addChild(alice, space, room, nil) + + rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(space)+"/hierarchy", alice.AccessToken) + expectStatus(t, rec, http.StatusOK) + raw := decode[struct { + Rooms []map[string]any `json:"rooms"` + }](t, rec) + if len(raw.Rooms) != 2 { + t.Fatalf("got %d rooms, want the space and its child", len(raw.Rooms)) + } + for _, r := range raw.Rooms { + children, ok := r["children_state"].([]any) + if !ok { + t.Errorf("%v: children_state = %#v, want a list", r["room_id"], r["children_state"]) + } + if r["room_id"] == room && len(children) != 0 { + t.Errorf("the leaf room has children_state %v", children) + } + } +}