diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 8a3985c..4291cfa 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -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 { Future createRoom(CreateRoomRequest request) async => (await _sendCommand("create_room", request.toJson()))["room_id"]; + Future getSpaceHierarchy( + GetSpaceHierarchyRequest request, + ) async => + .fromJson(await _sendCommand("get_space_hierarchy", request.toJson())); + Future getRoomSummary(JoinRoomRequest request) async => .fromJson(await _sendCommand("get_room_summary", request.toJson())); diff --git a/lib/controllers/space_hierarchy.dart b/lib/controllers/space_hierarchy.dart new file mode 100644 index 0000000..8a5aa9d --- /dev/null +++ b/lib/controllers/space_hierarchy.dart @@ -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 { + @override + Future build() => ref + .read(ClientController.provider.notifier) + .getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1)); + + Future 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.new, + ); +} diff --git a/lib/helpers/extensions/show_leave_room_sheet.dart b/lib/helpers/extensions/show_leave_room_sheet.dart new file mode 100644 index 0000000..a981d4b --- /dev/null +++ b/lib/helpers/extensions/show_leave_room_sheet.dart @@ -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 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\"?")], + ), + ); +} diff --git a/lib/models/requests/get_space_hierarchy.dart b/lib/models/requests/get_space_hierarchy.dart new file mode 100644 index 0000000..c0bdc04 --- /dev/null +++ b/lib/models/requests/get_space_hierarchy.dart @@ -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 toJson() => _$GetSpaceHierarchyRequestToJson(this); + + factory GetSpaceHierarchyRequest.fromJson(Map json) => + _$GetSpaceHierarchyRequestFromJson(json); +} diff --git a/lib/models/space_hierarchy.dart b/lib/models/space_hierarchy.dart new file mode 100644 index 0000000..76bb380 --- /dev/null +++ b/lib/models/space_hierarchy.dart @@ -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 rooms = const .empty(), + final String? nextBatch, +}) with _$SpaceHierarchy { + factory SpaceHierarchy.fromJson(Map 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 children = const .empty(), +}) with _$SpaceHierarchyRoom { + static Object? readEntry(Map json, String _) => json; + + factory SpaceHierarchyRoom.fromJson(Map 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 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 json) => + _$SpaceHierarchyChildFromJson(json); +} diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 9f00530..bd109f7 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -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,74 +254,97 @@ class const RoomsPane({super.key}) extends ConsumerWidget { ), body: MaterialUiCompatibilityBridge( child: Builder( - builder: (context) => old_mat.Theme( - data: old_mat.Theme.of(context).copyWith( - extensions: [ - NavigationRailM3ETheme( - itemExpandedHeight: 48, - iconLabelGap: 16, - ), - ], - ), - child: NavigationRailM3E( - expandedWidth: double.infinity, - scrollable: true, - background: Colors.transparent, - type: .alwaysExpand, - selectedIndex: max( - 0, - selectedSpace.allChildRooms.indexWhere( - (room) => room.metadata?.id == selectedRoomId, - ), + 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, + ), + ), + ], ), - sections: [ - .new( - header: selectedSpace.room == null - ? null - : const DividerWidget(Text("Rooms")), - destinations: selectedSpace.children.toDestinations( - isDm: isDm, + 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, + type: .alwaysExpand, + selectedIndex: max( + 0, + selectedSpace.allChildRooms.indexWhere( + (room) => room.metadata?.id == selectedRoomId, ), ), - ...selectedSpace.subSpaces.map( - (subSpace) => NavigationRailM3ESection( - header: DividerWidget( - Row( - mainAxisSize: .min, - spacing: 8, - children: [ - if (subSpace.room.metadata?.avatar != null) - AvatarOrHash( - subSpace.room.metadata?.avatar, - subSpace.room.metadata?.name ?? "Unnamed Room", - dimension: 16, + sections: [ + .new( + header: selectedSpace.room == null + ? null + : const DividerWidget(Text("Rooms")), + destinations: selectedSpace.children.toDestinations( + isDm: isDm, + ), + ), + ...selectedSpace.subSpaces.map( + (subSpace) => NavigationRailM3ESection( + header: DividerWidget( + Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (subSpace.room.metadata?.avatar != null) + AvatarOrHash( + subSpace.room.metadata?.avatar, + subSpace.room.metadata?.name ?? "Unnamed Room", + dimension: 16, + ), + Flexible( + child: Text( + subSpace.room.metadata?.name ?? "Unnamed Space", + maxLines: 1, + overflow: .ellipsis, + ), ), - Flexible( - child: Text( - subSpace.room.metadata?.name ?? "Unnamed Space", - maxLines: 1, - overflow: .ellipsis, - ), - ), - ], + ], + ), + ), + destinations: subSpace.children.toDestinations( + isDm: isDm, ), ), - destinations: subSpace.children.toDestinations(isDm: isDm), ), - ), - ], - onDestinationSelected: (value) { - ref - .read( - KeyController.provider(KeyController.roomKey).notifier, - ) - .set(selectedSpace.allChildRooms[value].metadata?.id); - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }, - ), - ), + ], + onDestinationSelected: (value) { + ref + .read( + KeyController.provider(KeyController.roomKey).notifier, + ) + .set(selectedSpace.allChildRooms[value].metadata?.id); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ); + }, ), ), ); diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index b7b52de..bc8e9ee 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -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), diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart new file mode 100644 index 0000000..7f8b588 --- /dev/null +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -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 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])), + ], + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 942ee76..6b755da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 77dc064..5ac6327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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