From 879e22141bbd1a32532c0cceee784d67f4b041b5 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:14:40 +0200 Subject: [PATCH] Mobile: spaces; /hierarchy always sends children_state Mobile: - The room list's title picks a space: All chats, or one space, which narrows the list to its rooms (and its subspaces' rooms). - A space browser over /hierarchy: the rooms of a space and of its subspaces, joined or joinable, with Join; for those who may, adding an existing room or a new one to the space. - New space in the new-chat sheet; invites to spaces show with the other invites and open the space when joined. - Fixes several setState calls that were handed a Future (reloading the members, sessions, push distributor, threads), which Flutter rejects. Server: /hierarchy left out children_state for a room without children. The spec requires it on every room (an empty list), and SpaceRoom's omitempty dropped empty lists too, despite its comment. The famedly Dart SDK fails to parse such a response. The client API now wraps each room so the field is always written; the federation response, which wants it left out of the children it lists, is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/lib/screens/home_screen.dart | 143 +++++++- apps/mobile/lib/screens/room_info_screen.dart | 4 +- apps/mobile/lib/screens/sessions_screen.dart | 5 +- apps/mobile/lib/screens/settings_screen.dart | 16 +- apps/mobile/lib/screens/space_screen.dart | 329 ++++++++++++++++++ .../lib/screens/thread_list_screen.dart | 4 +- apps/mobile/lib/state.dart | 12 + apps/mobile/lib/widgets/new_chat_sheet.dart | 95 ++++- server/internal/clientapi/spaces.go | 24 +- server/internal/clientapi/spaces_test.go | 29 ++ 10 files changed, 628 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/lib/screens/space_screen.dart 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) + } + } +}