Show space hierarchies

Fixes #83
This commit is contained in:
Henry Hiles 2026-10-15 22:34:27 -04:00
commit b71a7f0bd6
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
10 changed files with 519 additions and 98 deletions

View file

@ -28,6 +28,7 @@ import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart";
import "package:nexus/models/requests/oauth/exchange_token.dart";
@ -47,6 +48,7 @@ import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_metadata.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/models/spec_versions_response.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
@ -194,6 +196,11 @@ class ClientController extends AsyncNotifier<int> {
Future<String> createRoom(CreateRoomRequest request) async =>
(await _sendCommand("create_room", request.toJson()))["room_id"];
Future<SpaceHierarchy> getSpaceHierarchy(
GetSpaceHierarchyRequest request,
) async =>
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await _sendCommand("get_room_summary", request.toJson()));

View file

@ -0,0 +1,34 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/space_hierarchy.dart";
class SpaceHierarchyController(final String spaceId)
extends AsyncNotifier<SpaceHierarchy> {
@override
Future<SpaceHierarchy> build() => ref
.read(ClientController.provider.notifier)
.getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1));
Future<void> loadMore() async {
final current = await future;
if (current.nextBatch == null) return;
final next = await ref
.read(ClientController.provider.notifier)
.getSpaceHierarchy(
.new(roomId: spaceId, maxDepth: 1, from: current.nextBatch),
);
state = AsyncData(
current.copyWith(
rooms: current.rooms.addAll(next.rooms),
nextBatch: next.nextBatch,
),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<SpaceHierarchyController, SpaceHierarchy, String>(
SpaceHierarchyController.new,
);
}

View file

@ -0,0 +1,39 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/widgets/sheet_layout.dart";
extension ShowLeaveRoomSheet on BuildContext {
/// Asks the user to confirm leaving the room, then leaves it.
Future<void> showLeaveRoomSheet(String roomId, {required String name}) =>
showAdaptiveSheet(
(context) => SheetLayout(
title: const Text("Leave Room"),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
final client = ProviderScope.containerOf(
context,
).read(ClientController.provider.notifier);
context.withProgressSnackbar(
"Leaving $name...",
() => client.leaveRoom(roomId),
);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text("Leave"),
),
],
children: [Text("Are you sure you want to leave \"$name\"?")],
),
);
}

View file

@ -0,0 +1,19 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_space_hierarchy.freezed.dart";
part "get_space_hierarchy.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(includeIfNull: false)
class const GetSpaceHierarchyRequest({
required final String roomId,
final String? from,
final int limit = 50,
final int? maxDepth,
final bool? suggestedOnly,
}) with _$GetSpaceHierarchyRequest {
Map<String, Object?> toJson() => _$GetSpaceHierarchyRequestToJson(this);
factory GetSpaceHierarchyRequest.fromJson(Map<String, Object?> json) =>
_$GetSpaceHierarchyRequestFromJson(json);
}

View file

@ -0,0 +1,45 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room_summary.dart";
part "space_hierarchy.freezed.dart";
part "space_hierarchy.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const SpaceHierarchy({
final IList<SpaceHierarchyRoom> rooms = const .empty(),
final String? nextBatch,
}) with _$SpaceHierarchy {
factory SpaceHierarchy.fromJson(Map<String, Object?> json) =>
_$SpaceHierarchyFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const SpaceHierarchyRoom({
@JsonKey(readValue: SpaceHierarchyRoom.readEntry)
required final RoomSummary summary,
@JsonKey(name: "children_state")
final IList<SpaceHierarchyChild> children = const .empty(),
}) with _$SpaceHierarchyRoom {
static Object? readEntry(Map json, String _) => json;
factory SpaceHierarchyRoom.fromJson(Map<String, Object?> json) =>
_$SpaceHierarchyRoomFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const SpaceHierarchyChild({
@JsonKey(name: "state_key") required final String roomId,
@JsonKey(readValue: SpaceHierarchyChild.readContent)
final IList<String> via = const .empty(),
@JsonKey(readValue: SpaceHierarchyChild.readContent)
final bool suggested = false,
}) with _$SpaceHierarchyChild {
static Object? readContent(Map json, String key) => json["content"]?[key];
factory SpaceHierarchyChild.fromJson(Map<String, Object?> json) =>
_$SpaceHierarchyChildFromJson(json);
}

View file

@ -2,6 +2,7 @@ import "dart:math";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:m3e_design/m3e_design.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/helpers/extensions/room_destinations.dart";
@ -16,6 +17,7 @@ import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/join_sheet.dart";
import "package:nexus/widgets/room_menu_button.dart";
import "package:nexus/widgets/space_hierarchy_sheet.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/spaces.dart";
@ -252,16 +254,36 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
),
body: MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
builder: (context) {
final theme = old_mat.Theme.of(context);
return old_mat.Theme(
data: theme.copyWith(
extensions: [
NavigationRailM3ETheme(
itemExpandedHeight: 48,
iconLabelGap: 16,
sectionHeaderSpacingTop: 0,
),
// Otherwise the FAB label falls back to Roboto
M3ETheme.defaults(theme.colorScheme).copyWith(
typography: .defaultFor(
theme.brightness,
baseOverride: theme.textTheme,
),
),
],
),
child: NavigationRailM3E(
fab: selectedSpace.room == null
? null
: .new(
icon: const Icon(Icons.explore),
label: "Browse space",
tooltip: "Browse and join rooms in this space",
onPressed: () => context.showAdaptiveSheet(
(_) => SpaceHierarchySheet(selectedSpace.id),
),
),
expandedWidth: double.infinity,
scrollable: true,
background: Colors.transparent,
@ -304,7 +326,9 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
],
),
),
destinations: subSpace.children.toDestinations(isDm: isDm),
destinations: subSpace.children.toDestinations(
isDm: isDm,
),
),
),
],
@ -319,7 +343,8 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
}
},
),
),
);
},
),
),
);

View file

@ -1,7 +1,7 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/helpers/extensions/show_leave_room_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter/services.dart";
@ -123,31 +123,9 @@ final class const RoomMenuButton(
),
),
PopupMenuItem(
onTap: () => context.showAdaptiveSheet(
(context) => SheetLayout(
title: Text("Leave Room"),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
context.withProgressSnackbar(
"Leaving room...",
() => client.leaveRoom(room!.metadata!.id),
);
Navigator.of(context).pop();
},
child: Text("Leave"),
),
],
children: [
Text(
"Are you sure you want to leave \"${room!.metadata?.name ?? "Unnamed Room"}\"?",
),
],
),
onTap: () => context.showLeaveRoomSheet(
room!.metadata!.id,
name: room!.metadata?.name ?? "Unnamed Room",
),
child: ListTile(
leading: Icon(Icons.logout, color: danger),

View file

@ -0,0 +1,263 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_hierarchy.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/join_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/show_leave_room_sheet.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const SpaceHierarchySheet(final String spaceId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final joinedIds = ref.watch(
RoomsController.provider.select((rooms) => rooms.keys.toISet()),
);
void showSummary(SpaceHierarchyRoom room, SpaceHierarchyChild child) =>
context.showAdaptiveSheet(
(_) => RoomSummarySheet(
room.summary,
via: joinedIds.contains(room.summary.roomId) ? null : child.via,
),
);
Widget membershipButton(
SpaceHierarchyRoom room,
SpaceHierarchyChild child, {
required String name,
}) => joinedIds.contains(room.summary.roomId)
? TextButton(
onPressed: () =>
context.showLeaveRoomSheet(room.summary.roomId, name: name),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text("Leave"),
)
: TextButton(
onPressed: () => context.joinRoom(
.new(roomIdOrAlias: room.summary.roomId, via: child.via),
name: name,
),
child: const Text("Join"),
);
Widget level(
String spaceId,
SpaceHierarchy hierarchy,
ISet<String> ancestors,
) => HookConsumer(
builder: (context, ref, _) {
final loadingMore = useState(false);
final byId = IMap.fromValues(
keyMapper: (room) => room.summary.roomId,
values: hierarchy.rooms,
);
final children = (byId[spaceId]?.children ?? const .empty())
.map(
(child) => switch (byId[child.roomId]) {
// Skip spaces that contain themselves somewhere up the tree.
final room? when !ancestors.contains(child.roomId) => (
child,
room,
),
_ => null,
},
)
.nonNulls
// Suggested rooms first, then sorted by member count
.sorted((a, b) {
if (a.$1.suggested != b.$1.suggested) {
return a.$1.suggested ? -1 : 1;
}
return (b.$2.summary.joinedMembers ?? 0).compareTo(
a.$2.summary.joinedMembers ?? 0,
);
});
return Column(
crossAxisAlignment: .stretch,
children: [
for (final (child, room) in children)
if (room.summary.roomType != RoomType.space)
Builder(
builder: (context) {
final summary = room.summary;
final name =
summary.name ??
summary.canonicalAlias ??
"Unnamed Room";
return ListTile(
leading: AvatarOrHash(
summary.avatarUrl,
name,
fallback: const Icon(Icons.numbers),
),
title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: summary.topic == null
? null
: Text(
summary.topic!,
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => showSummary(room, child),
trailing: membershipButton(room, child, name: name),
);
},
),
for (final (child, room) in children)
if (room.summary.roomType == RoomType.space)
HookConsumer(
builder: (context, ref, _) {
final expanded = useState(false);
final summary = room.summary;
final name =
summary.name ??
summary.canonicalAlias ??
"Unnamed Space";
final joined = joinedIds.contains(summary.roomId);
return Column(
crossAxisAlignment: .stretch,
children: [
ListTile(
onTap: joined
? () => expanded.value = !expanded.value
: () => showSummary(room, child),
leading: AvatarOrHash(
summary.avatarUrl,
name,
fallback: const Icon(Icons.workspaces),
),
title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: summary.topic == null
? null
: Text(
summary.topic!,
maxLines: 2,
overflow: .ellipsis,
),
trailing: Row(
mainAxisSize: .min,
children: [
if (joined)
AnimatedRotation(
turns: expanded.value ? 0.5 : 0,
duration: Durations.medium2,
child: const Icon(Icons.expand_more),
),
membershipButton(room, child, name: name),
],
),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: joined && expanded.value
? Padding(
// Indented so it's clear which rooms are in which space
padding: const .only(left: 16),
child: ref
.watch(
SpaceHierarchyController.provider(
summary.roomId,
),
)
.betterWhen(
loading: () =>
const Loading(dimension: 24),
data: (hierarchy) => level(
summary.roomId,
hierarchy,
ancestors.add(child.roomId),
),
),
)
: const SizedBox(width: .infinity),
),
],
);
},
),
if (hierarchy.nextBatch != null)
Center(
child: TextButton(
onPressed: loadingMore.value
? null
: () async {
loadingMore.value = true;
await ref
.read(
SpaceHierarchyController.provider(spaceId)
.notifier,
)
.loadMore()
.onError(showError);
if (context.mounted) loadingMore.value = false;
},
child: const Text("Load more"),
),
),
],
);
},
);
return ref
.watch(SpaceHierarchyController.provider(spaceId))
.betterWhen(
data: (hierarchy) {
final space = hierarchy.rooms
.where((room) => room.summary.roomId == spaceId)
.firstOrNull
?.summary;
return SheetLayout(
title: Row(
spacing: 12,
children: [
if (space?.avatarUrl case final avatarUrl?)
ExpandableImage(
.new(mxc: avatarUrl),
child: AvatarOrHash(avatarUrl, "", dimension: 32),
),
Expanded(
child: Text(
space?.name ?? space?.canonicalAlias ?? "Unnamed Space",
overflow: .ellipsis,
maxLines: 3,
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Close"),
),
],
children: [
level(spaceId, hierarchy, .new([spaceId])),
],
);
},
);
}
}

View file

@ -314,12 +314,13 @@ packages:
source: hosted
version: "2.1.0"
fab_m3e:
dependency: transitive
dependency: "direct overridden"
description:
name: fab_m3e
sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59
url: "https://pub.dev"
source: hosted
path: "packages/fab_m3e"
ref: HEAD
resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96"
url: "https://github.com/Henry-Hiles/material_3_expressive"
source: git
version: "0.1.1"
fake_async:
dependency: transitive
@ -863,12 +864,13 @@ packages:
source: hosted
version: "1.0.0"
m3e_design:
dependency: transitive
dependency: "direct main"
description:
name: m3e_design
sha256: "15ff0ef4c43553d855c5e866a9aee8231d44919fe2bb354b1259337bdfd659b4"
url: "https://pub.dev"
source: hosted
path: "packages/m3e_design"
ref: HEAD
resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96"
url: "https://github.com/Henry-Hiles/material_3_expressive"
source: git
version: "0.2.1"
matcher:
dependency: transitive
@ -1011,7 +1013,7 @@ packages:
description:
path: "packages/navigation_rail_m3e"
ref: HEAD
resolved-ref: da860a413d5cf78b9c9c967215250bcdbfe5191d
resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96"
url: "https://github.com/Henry-Hiles/material_3_expressive"
source: git
version: "0.3.5"

View file

@ -22,6 +22,14 @@ environment:
sdk: 3.13.3
dependency_overrides:
m3e_design:
git:
url: https://github.com/Henry-Hiles/material_3_expressive
path: packages/m3e_design
fab_m3e:
git:
url: https://github.com/Henry-Hiles/material_3_expressive
path: packages/fab_m3e
dynamic_color: 2.1.0
unifiedpush_linux:
git:
@ -79,6 +87,7 @@ dependencies:
media_kit_libs_video: 1.0.7
measure_size: 5.0.2
m3e_buttons: 1.0.3
m3e_design: 0.2.1
navigation_rail_m3e:
git:
url: https://github.com/Henry-Hiles/material_3_expressive