From e382499a2a6bc28620f6c0f7fa82d5a55f8a2eec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 4 Nov 2026 12:43:01 -0500 Subject: [PATCH] add room directory support --- lib/controllers/client.dart | 7 + lib/controllers/room_directory.dart | 39 ++ lib/helpers/extensions/get_localpart.dart | 6 + lib/helpers/extensions/join_room.dart | 2 + .../extensions/room_summary_display.dart | 18 + .../content/bot_command_description.dart | 7 +- lib/models/requests/get_room_directory.dart | 28 ++ lib/models/room_directory.dart | 17 + lib/models/settings.dart | 5 + lib/widgets/add_directory_server_sheet.dart | 54 +++ lib/widgets/invites_list.dart | 10 +- lib/widgets/m3e_rail_theme.dart | 36 ++ lib/widgets/pages/explore.dart | 338 +++++++++++++++ lib/widgets/room_directory_card.dart | 130 ++++++ lib/widgets/room_list.dart | 386 ++++++++---------- lib/widgets/room_summary_sheet.dart | 9 +- lib/widgets/settings/settings_layout.dart | 5 +- lib/widgets/space_hierarchy_sheet.dart | 22 +- 18 files changed, 871 insertions(+), 248 deletions(-) create mode 100644 lib/controllers/room_directory.dart create mode 100644 lib/helpers/extensions/room_summary_display.dart create mode 100644 lib/models/requests/get_room_directory.dart create mode 100644 lib/models/room_directory.dart create mode 100644 lib/widgets/add_directory_server_sheet.dart create mode 100644 lib/widgets/m3e_rail_theme.dart create mode 100644 lib/widgets/pages/explore.dart create mode 100644 lib/widgets/room_directory_card.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 361b4e6..62b4502 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -36,6 +36,7 @@ import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_specific_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; +import "package:nexus/models/requests/get_room_directory.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; import "package:nexus/models/push_notification.dart"; @@ -57,6 +58,7 @@ import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/update_push_rule.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/models/room_directory.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/spec_versions_response.dart"; @@ -311,6 +313,11 @@ class ClientController(final String? accountId) extends AsyncNotifier { ) async => .fromJson(await _sendCommand("get_space_hierarchy", request.toJson())); + Future getRoomDirectory( + GetRoomDirectoryRequest request, + ) async => + .fromJson(await _sendCommand("get_room_directory", request.toJson())); + Future getRoomSummary(JoinRoomRequest request) async => .fromJson(await _sendCommand("get_room_summary", request.toJson())); diff --git a/lib/controllers/room_directory.dart b/lib/controllers/room_directory.dart new file mode 100644 index 0000000..8d6e498 --- /dev/null +++ b/lib/controllers/room_directory.dart @@ -0,0 +1,39 @@ +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/room_directory.dart"; + +/// The public room directory of a server, or of the user's own if null. +class RoomDirectoryController(final String? server) + extends AsyncNotifier { + @override + Future build() => ref + .read(ClientController.provider.notifier) + .getRoomDirectory(.new(server: server)); + + Future loadMore() async { + final current = await future; + if (current.nextBatch == null) return; + + final next = await ref + .read(ClientController.provider.notifier) + .getRoomDirectory(.new(server: server, since: current.nextBatch)); + + if (!ref.mounted) return; + + state = AsyncData( + current.copyWith( + rooms: current.rooms.addAll(next.rooms), + nextBatch: next.nextBatch, + ), + ); + } + + /// Loading the next page of a directory, keyed by its server + static final loadMoreMutation = Mutation(); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomDirectoryController.new, + ); +} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart index 71e9808..200d2d0 100644 --- a/lib/helpers/extensions/get_localpart.dart +++ b/lib/helpers/extensions/get_localpart.dart @@ -4,6 +4,12 @@ extension GetLocalpart on String { /// Whether this looks like a valid Matrix user ID, e.g. `@user:example.com`. bool get isValidUserId => RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(this); + /// Whether this is a valid Matrix server name, e.g. `example.com:8448`. + bool get isValidServerName => RegExp( + r"^(\[[0-9a-f:.]+\]|[a-z0-9.-]{1,255})(:\d{1,5})?$", + caseSensitive: false, + ).hasMatch(this); + String? get serverName { final index = indexOf(":"); return index == -1 ? null : substring(index + 1); diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart index df939c1..03140f9 100644 --- a/lib/helpers/extensions/join_room.dart +++ b/lib/helpers/extensions/join_room.dart @@ -17,6 +17,7 @@ extension JoinRoom on BuildContext { }) async { final container = ProviderScope.containerOf(this); final messenger = ScaffoldMessenger.of(this); + final navigator = Navigator.of(this); final roomId = await withProgressSnackbar( "Joining $name...", @@ -31,6 +32,7 @@ extension JoinRoom on BuildContext { action: .new( label: "Open", onPressed: () async { + navigator.popUntil((route) => route.isFirst); await container.focusRoom(roomId); container.read(SidebarPageController.provider.notifier).set(.rooms); }, diff --git a/lib/helpers/extensions/room_summary_display.dart b/lib/helpers/extensions/room_summary_display.dart new file mode 100644 index 0000000..7b59a16 --- /dev/null +++ b/lib/helpers/extensions/room_summary_display.dart @@ -0,0 +1,18 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/room_summary.dart"; + +extension RoomSummaryDisplay on RoomSummary { + bool get isSpace => roomType == RoomType.space; + + IconData get icon => isSpace ? Icons.workspaces : Icons.numbers; + + String get displayName => + name ?? canonicalAlias ?? (isSpace ? "Unnamed Space" : "Unnamed Room"); + + /// E.g. "Space · 12 members" + String get details => [ + if (isSpace) "Space", + if (joinedMembers case final members?) "$members members", + ].join(" · "); +} diff --git a/lib/models/content/bot_command_description.dart b/lib/models/content/bot_command_description.dart index 497887e..f563d9a 100644 --- a/lib/models/content/bot_command_description.dart +++ b/lib/models/content/bot_command_description.dart @@ -2,6 +2,7 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/content/content.dart"; part "bot_command_description.freezed.dart"; @@ -181,10 +182,6 @@ enum BotPrimitiveType(final String label, final String expected) { @JsonValue("event_id") eventId("event", r"a message link or event ID like $abc"); - static final serverNamePattern = RegExp( - r"^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$", - ); - Object? parse(String text, {required String roomId}) { final (:identifier, :eventId, :via) = text.linkInfo ?? @@ -206,7 +203,7 @@ enum BotPrimitiveType(final String label, final String expected) { identifier, .roomAlias when identifier.startsWith("#") && identifier.contains(":") => identifier, - .serverName when serverNamePattern.hasMatch(text) => text, + .serverName when text.isValidServerName => text, .roomId when identifier.startsWith("!") && eventId == null => { "type": "room_id", "id": identifier, diff --git a/lib/models/requests/get_room_directory.dart b/lib/models/requests/get_room_directory.dart new file mode 100644 index 0000000..e46f203 --- /dev/null +++ b/lib/models/requests/get_room_directory.dart @@ -0,0 +1,28 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_room_directory.freezed.dart"; +part "get_room_directory.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class const GetRoomDirectoryRequest({ + final String? server, + final int limit = 48, + final String? since, + final PublicRoomsFilter? filter, +}) with _$GetRoomDirectoryRequest { + Map toJson() => _$GetRoomDirectoryRequestToJson(this); + + factory GetRoomDirectoryRequest.fromJson(Map json) => + _$GetRoomDirectoryRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PublicRoomsFilter({final String? genericSearchTerm}) + with _$PublicRoomsFilter { + Map toJson() => _$PublicRoomsFilterToJson(this); + + factory PublicRoomsFilter.fromJson(Map json) => + _$PublicRoomsFilterFromJson(json); +} diff --git a/lib/models/room_directory.dart b/lib/models/room_directory.dart new file mode 100644 index 0000000..fa58e20 --- /dev/null +++ b/lib/models/room_directory.dart @@ -0,0 +1,17 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room_summary.dart"; + +part "room_directory.freezed.dart"; +part "room_directory.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const RoomDirectory({ + @JsonKey(name: "chunk") final IList rooms = const .empty(), + final String? nextBatch, + final int? totalRoomCountEstimate, +}) with _$RoomDirectory { + factory RoomDirectory.fromJson(Map json) => + _$RoomDirectoryFromJson(json); +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e3b95a2..79c3d64 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,3 +1,4 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/name_color.dart"; @@ -25,6 +26,10 @@ class const Settings({ /// When not sending, read receipts are still sent privately, so that the /// user's other devices know what has been read. final ShowAndSend readReceipts = .showAndSend, + final IList directoryServers = const IListConst([ + "matrix.org", + "matrixrooms.info", + ]), }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/widgets/add_directory_server_sheet.dart b/lib/widgets/add_directory_server_sheet.dart new file mode 100644 index 0000000..2fefe3b --- /dev/null +++ b/lib/widgets/add_directory_server_sheet.dart @@ -0,0 +1,54 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; + +class const AddDirectoryServerSheet({ + required final ISet existing, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final text = useValueListenable(controller).text; + final server = text.trim().toLowerCase(); + final error = server.isEmpty + ? null + : !server.isValidServerName + ? "Enter a server name, without https:// or a path" + : existing.contains(server) + ? "This server has already been added" + : null; + final valid = server.isNotEmpty && error == null; + + void submit() { + if (valid) Navigator.of(context).pop(server); + } + + return SheetLayout( + title: const Text("Add Server"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton(onPressed: valid ? submit : null, child: const Text("Add")), + ], + children: [ + const Text("Enter a server to browse the public rooms of."), + TextField( + controller: controller, + autofocus: true, + keyboardType: .url, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "Server name", + hintText: "matrix.org", + errorText: error, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/invites_list.dart b/lib/widgets/invites_list.dart index f8847c4..68d8b3b 100644 --- a/lib/widgets/invites_list.dart +++ b/lib/widgets/invites_list.dart @@ -3,8 +3,8 @@ import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/invites.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; import "package:nexus/main.dart"; -import "package:nexus/models/content/create.dart"; import "package:nexus/models/nav_page.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -120,13 +120,7 @@ class const InviteCard(final RoomSummary invite, {super.key}) invite.avatarUrl, name, dimension: 40, - fallback: invite.isDirect - ? null - : Icon( - invite.roomType == RoomType.space - ? Icons.workspaces - : Icons.numbers, - ), + fallback: invite.isDirect ? null : Icon(invite.icon), ), title: Text(name, maxLines: 1, overflow: .ellipsis), subtitle: Text( diff --git a/lib/widgets/m3e_rail_theme.dart b/lib/widgets/m3e_rail_theme.dart new file mode 100644 index 0000000..3ca5ed2 --- /dev/null +++ b/lib/widgets/m3e_rail_theme.dart @@ -0,0 +1,36 @@ +import "package:material_ui/material_ui.dart"; +import "package:m3e_design/m3e_design.dart"; + +// Needed for navigation_rail_m3e (#65). +import "package:flutter/material.dart" as old_mat; + +/// Themes a navigation_rail_m3e rail, which still uses the old Material theme. +class const M3ERailTheme({ + required final WidgetBuilder builder, + final List extensions = const [], + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) { + final theme = old_mat.Theme.of(context); + return old_mat.Theme( + data: theme.copyWith( + extensions: [ + ...extensions, + // Otherwise the FAB label falls back to Roboto + M3ETheme.defaults(theme.colorScheme).copyWith( + typography: .defaultFor( + theme.brightness, + baseOverride: theme.textTheme, + ), + ), + ], + ), + child: Builder(builder: builder), + ); + }, + ), + ); +} diff --git a/lib/widgets/pages/explore.dart b/lib/widgets/pages/explore.dart new file mode 100644 index 0000000..bc8ab2d --- /dev/null +++ b/lib/widgets/pages/explore.dart @@ -0,0 +1,338 @@ +import "dart:math"; + +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:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_directory.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/main.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/widgets/add_directory_server_sheet.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/m3e_rail_theme.dart"; +import "package:nexus/widgets/room_directory_card.dart"; + +class const ExplorePage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final ownServer = ref.watch( + ClientStateController.provider.select( + (state) => state?.userId?.serverName, + ), + ); + final savedServers = ref + .watch( + SettingsController.provider.select( + (settings) => settings.value?.directoryServers, + ), + ) + ?.where((server) => server != ownServer) + .toIList(); + + // Null is the user's own server + final selected = useState(null); + final servers = IList([null, ...?savedServers]); + String labelOf(String? server) => server ?? ownServer ?? "Home"; + IconData iconOf(String? server) => server == null ? Icons.home : Icons.dns; + + final current = servers[max(0, servers.indexOf(selected.value))]; + final provider = RoomDirectoryController.provider(current); + final loadMore = RoomDirectoryController.loadMoreMutation(current); + final loadingMore = ref.watch(loadMore).isPending; + final directory = ref.watch(provider); + final theme = Theme.of(context); + + final via = IList([?current ?? ownServer]); + + Future addServer() async { + final server = await context.showAdaptiveSheet( + (_) => AddDirectoryServerSheet( + existing: .new([?ownServer, ...servers.nonNulls]), + ), + ); + if (server == null) return; + + final settings = await ref.read(SettingsController.provider.future); + await ref + .read(SettingsController.provider.notifier) + .set( + settings.copyWith( + directoryServers: settings.directoryServers.add(server), + ), + ); + if (context.mounted) selected.value = server; + } + + Future removeServer(String server) async { + selected.value = null; + final settings = await ref.read(SettingsController.provider.future); + await ref + .read(SettingsController.provider.notifier) + .set( + settings.copyWith( + directoryServers: settings.directoryServers.remove(server), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 700; + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + isFullScreen: !isWide, + suggestionsBuilder: (suggestionsContext, controller) async { + final text = controller.text.trim(); + if (text.isEmpty) return const []; + + // Each search hits the server, so wait for the user to stop typing + await Future.delayed(Durations.long2); + if (controller.text.trim() != text) return const []; + + try { + final directory = await ref + .read(ClientController.provider.notifier) + .getRoomDirectory( + .new( + server: current, + limit: 20, + filter: .new(genericSearchTerm: text), + ), + ); + return directory.rooms.map( + (summary) => Consumer( + builder: (_, ref, _) { + final joined = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.containsKey(summary.roomId), + ), + ); + + return ListTile( + onTap: () { + Navigator.of(suggestionsContext).pop(); + controller.text = ""; + + context.showAdaptiveSheet( + (_) => RoomSummarySheet( + summary, + via: joined ? null : via, + ), + ); + }, + leading: AvatarOrHash( + summary.avatarUrl, + summary.displayName, + fallback: Icon(summary.icon), + ), + title: Text(summary.displayName), + subtitle: Text( + [ + summary.details, + if (joined) "Joined", + ].where((part) => part.isNotEmpty).join(" · "), + ), + ); + }, + ), + ); + } catch (error) { + return [ + ListTile( + leading: const Icon(Icons.error_outline), + title: const Text("Couldn't search this server"), + subtitle: Text(error.toString()), + ), + ]; + } + }, + ); + + final results = CustomScrollView( + slivers: [ + SliverPadding( + padding: const .all(16), + sliver: SliverList.list( + children: [ + if (!isWide) + Padding( + padding: const .only(bottom: 12), + child: SingleChildScrollView( + scrollDirection: .horizontal, + child: Row( + spacing: 8, + children: [ + ...servers.map( + (server) => ChoiceChip( + avatar: Icon(iconOf(server)), + label: Text(labelOf(server)), + selected: current == server, + onSelected: (_) => selected.value = server, + ), + ), + ActionChip( + avatar: const Icon(Icons.add), + label: const Text("Add server"), + onPressed: () => addServer().onError(showError), + ), + ], + ), + ), + ), + searchBar, + const SizedBox(height: 16), + Text(switch (directory.value?.totalRoomCountEstimate) { + final count? when count > 0 => "Popular Rooms ($count)", + _ => "Popular Rooms", + }, style: theme.textTheme.titleLarge), + ], + ), + ), + ...switch (directory) { + AsyncData(:final value) when value.rooms.isEmpty => [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: theme.colorScheme.onSurface, + ), + const SizedBox(height: 12), + Text( + "No rooms found", + style: theme.textTheme.headlineSmall, + ), + ], + ), + ), + ), + ], + AsyncData(:final value) || AsyncLoading(:final value?) => [ + SliverPadding( + padding: const .symmetric(horizontal: 16), + sliver: SliverLayoutBuilder( + builder: (_, constraints) => SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max( + 1, + (constraints.crossAxisExtent + 12) ~/ (280 + 12), + ), + mainAxisExtent: 210, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: value.rooms.length, + itemBuilder: (_, index) => + RoomDirectoryCard(value.rooms[index], via: via), + ), + ), + ), + SliverPadding( + padding: const .all(16), + sliver: SliverToBoxAdapter( + child: value.nextBatch == null + ? null + : Center( + child: TextButton( + onPressed: loadingMore + ? null + : () => loadMore + .run( + ref, + (transaction) => transaction + .get(provider.notifier) + .loadMore(), + ) + .onError(showError), + child: const Text("Load more"), + ), + ), + ), + ), + ], + AsyncError(:final error, :final stackTrace) => [ + SliverFillRemaining( + hasScrollBody: false, + child: ErrorDialog(error, stackTrace, provider: provider), + ), + ], + AsyncLoading() => [ + const SliverFillRemaining( + hasScrollBody: false, + child: Loading(), + ), + ], + }, + ], + ); + + return Scaffold( + appBar: AppBar( + title: const Text("Explore Rooms"), + actionsPadding: const .symmetric(horizontal: 12), + actions: [ + if (current case final server?) + IconButton( + tooltip: "Remove server", + onPressed: () => removeServer(server).onError(showError), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + body: isWide + ? Row( + children: [ + M3ERailTheme( + builder: (_) => NavigationRailM3E( + type: .alwaysExpand, + scrollable: true, + fab: .new( + icon: const Icon(Icons.add), + label: "Add server", + tooltip: "Add a server to browse", + onPressed: () => addServer().onError(showError), + ), + sections: [ + .new( + header: const DividerText("Servers"), + destinations: servers + .map( + (server) => NavigationRailM3EDestination( + icon: Icon(iconOf(server)), + label: labelOf(server), + ), + ) + .toList(), + ), + ], + selectedIndex: servers.indexOf(current), + onDestinationSelected: (index) => + selected.value = servers[index], + ), + ), + const VerticalDivider(), + Expanded(child: results), + ], + ) + : results, + ); + }, + ); + } +} diff --git a/lib/widgets/room_directory_card.dart b/lib/widgets/room_directory_card.dart new file mode 100644 index 0000000..e8d8bfc --- /dev/null +++ b/lib/widgets/room_directory_card.dart @@ -0,0 +1,130 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/knock_sheet.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; + +class const RoomDirectoryCard( + final RoomSummary summary, { + required final IList via, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final joined = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.containsKey(summary.roomId), + ), + ); + final mustKnock = ref.watch( + RoomsController.provider.select( + (rooms) => summary.mustKnock( + summary.allowedRoomIds.where(rooms.containsKey).toISet(), + ), + ), + ); + final theme = Theme.of(context); + final name = summary.displayName; + + return Card.filled( + clipBehavior: .antiAlias, + margin: .zero, + child: InkWell( + onTap: () => context.showAdaptiveSheet( + (_) => RoomSummarySheet(summary, via: joined ? null : via), + ), + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + Row( + spacing: 12, + children: [ + AvatarOrHash( + summary.avatarUrl, + name, + dimension: 48, + fallback: Icon(summary.icon), + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + name, + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.titleMedium, + ), + if (summary.details case final details + when details.isNotEmpty) + Text( + details, + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + Expanded( + child: Text( + summary.topic ?? summary.canonicalAlias ?? "", + maxLines: 3, + overflow: .ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Align( + alignment: .centerRight, + child: joined + ? FilledButton.tonal( + onPressed: () async { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + await container.focusRoom(summary.roomId); + container + .read(SidebarPageController.provider.notifier) + .set(.rooms); + }, + child: const Text("Open"), + ) + : mustKnock + ? FilledButton( + onPressed: () => context.showAdaptiveSheet( + (_) => + KnockSheet(summary.roomId, name: name, via: via), + ), + child: const Text("Knock"), + ) + : FilledButton( + onPressed: () => context.joinRoom( + .new(roomIdOrAlias: summary.roomId, via: via), + name: name, + ), + child: const Text("Join"), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 316e020..bdbf9cd 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -3,7 +3,6 @@ import "dart:math"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; @@ -17,8 +16,10 @@ import "package:nexus/widgets/create_room_sheet.dart"; import "package:nexus/widgets/start_dm_sheet.dart"; import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/pages/explore.dart"; import "package:nexus/widgets/pages/settings.dart"; import "package:nexus/widgets/join_sheet.dart"; +import "package:nexus/widgets/m3e_rail_theme.dart"; import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_menu_button.dart"; import "package:nexus/widgets/space_hierarchy_sheet.dart"; @@ -28,9 +29,6 @@ import "package:nexus/controllers/selected_space.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/spaces.dart"; -// Needed for navigation_rail_m3e (#65). -import "package:flutter/material.dart" as old_mat; - class const RoomList({super.key}) extends ConsumerWidget implements NavPage { @override String get title => "Rooms"; @@ -92,123 +90,112 @@ class const SpacesRail({super.key}) extends ConsumerWidget { .select((async) => async.requireValue), ); - return MaterialUiCompatibilityBridge( - child: Builder( - builder: (context) => old_mat.Theme( - data: old_mat.Theme.of(context).copyWith( - extensions: [ - NavigationRailM3ETheme( - itemCollapsedHeight: 48, - itemVerticalGap: 0, - ), - ], - ), - child: Container( - color: NavigationRailTokensAdapter(context).containerColor, - padding: .only(top: 16), - child: NavigationRailM3E( - type: .alwaysCollapse, - labelBehavior: .alwaysHide, - scrollable: true, - onDestinationSelected: (value) { - ref - .read( - KeyController.provider(KeyController.spaceKey).notifier, - ) - .set(spaces[value].id); - ref - .read( - KeyController.provider(KeyController.roomKey).notifier, - ) - .set(spaces[value].children.firstOrNull?.metadata?.id); - }, - sections: [ - .new( - destinations: spaces.map((space) { - final notifications = space.allChildRooms.fold( - 0, - (sum, room) => - sum + (room.metadata?.unreadNotifications ?? 0), - ); + return M3ERailTheme( + extensions: [ + NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0), + ], + builder: (context) => Container( + color: NavigationRailTokensAdapter(context).containerColor, + padding: .only(top: 16), + child: NavigationRailM3E( + type: .alwaysCollapse, + labelBehavior: .alwaysHide, + scrollable: true, + onDestinationSelected: (value) { + ref + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(spaces[value].id); + ref + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(spaces[value].children.firstOrNull?.metadata?.id); + }, + sections: [ + .new( + destinations: spaces.map((space) { + final notifications = space.allChildRooms.fold( + 0, + (sum, room) => + sum + (room.metadata?.unreadNotifications ?? 0), + ); - return NavigationRailM3EDestination( - badgeCount: notifications > 0 - ? notifications - : space.allChildRooms.any( - (room) => room.metadata?.unreadMessages != 0, - ) - ? 0 - : null, - short: true, - icon: AvatarOrHash( - dimension: 28, - space.room?.metadata?.avatar, - fallback: space.icon == null ? null : Icon(space.icon), - space.title, + return NavigationRailM3EDestination( + badgeCount: notifications > 0 + ? notifications + : space.allChildRooms.any( + (room) => room.metadata?.unreadMessages != 0, + ) + ? 0 + : null, + short: true, + icon: AvatarOrHash( + dimension: 28, + space.room?.metadata?.avatar, + fallback: space.icon == null ? null : Icon(space.icon), + space.title, + ), + label: space.title, + ); + }).toList(), + ), + ], + selectedIndex: max( + 0, + spaces.indexWhere((space) => space.id == selectedSpaceId), + ), + trailingAtBottom: true, + trailing: Padding( + padding: .only(top: 8), + child: Column( + children: [ + AdaptiveMenuButton( + tooltip: "Add a room", + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => + context.showAdaptiveSheet((_) => JoinSheet(ref)), + child: const ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), ), - label: space.title, - ); - }).toList(), - ), - ], - selectedIndex: max( - 0, - spaces.indexWhere((space) => space.id == selectedSpaceId), - ), - trailingAtBottom: true, - trailing: Padding( - padding: .only(top: 8), - child: Column( - children: [ - AdaptiveMenuButton( - tooltip: "Add a room", - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => - context.showAdaptiveSheet((_) => JoinSheet(ref)), - child: const ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: () => context.showAdaptiveSheet( - (_) => const CreateRoomSheet(), - ), - child: const ListTile( - title: Text("Create a new room (or space)"), - leading: Icon(Icons.add), - ), - ), - PopupMenuItem( - onTap: () => context.showAdaptiveSheet( - (_) => const StartDmSheet(), - ), - child: const ListTile( - title: Text("Start a direct message"), - leading: Icon(Icons.person_add), - ), - ), - ], - icon: const Icon(Icons.add), ), - const AccountSwitcher(), - const IconButton( - tooltip: "Explore other rooms (Not yet implemented)", - onPressed: null, - icon: Icon(Icons.explore), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const CreateRoomSheet(), + ), + child: const ListTile( + title: Text("Create a new room (or space)"), + leading: Icon(Icons.add), + ), ), - IconButton( - tooltip: "Open settings", - onPressed: () => showDialog( - context: context, - builder: (_) => SettingsPage(), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const StartDmSheet(), + ), + child: const ListTile( + title: Text("Start a direct message"), + leading: Icon(Icons.person_add), ), - icon: const Icon(Icons.settings), ), ], + icon: const Icon(Icons.add), ), - ), + const AccountSwitcher(), + IconButton( + tooltip: "Explore other rooms", + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExplorePage()), + ), + icon: const Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: const Icon(Icons.settings), + ), + ], ), ), ), @@ -254,111 +241,86 @@ class const RoomsPane({super.key}) extends ConsumerWidget { ), ]), ), - body: MaterialUiCompatibilityBridge( - child: Builder( - 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, + body: M3ERailTheme( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + sectionHeaderSpacingTop: 0, + ), + ], + builder: (context) => Padding( + padding: const .only(top: 4), + 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), ), ), - ], - ), - child: Padding( - padding: const .only(top: 4), - 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, destinationIds.indexOf(selectedRoomId)), - sections: [ - if (selectedSpace.room case final room? - when spaceMetadata != null) - .new( - destinations: [ - room.toDestination( - isDm: isDm, - label: "Space timeline", - icon: const Icon(Icons.forum), - ), - ], - ), - .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, - ), - ), - ], - ), - ), - destinations: subSpace.children.toDestinations( - isDm: isDm, - ), - ), + expandedWidth: double.infinity, + scrollable: true, + background: Colors.transparent, + type: .alwaysExpand, + selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)), + sections: [ + if (selectedSpace.room case final room? + when spaceMetadata != null) + .new( + destinations: [ + room.toDestination( + isDm: isDm, + label: "Space timeline", + icon: const Icon(Icons.forum), ), ], - onDestinationSelected: (value) { - ref - .read( - KeyController.provider(KeyController.roomKey) - .notifier, - ) - .set(destinationIds[value]); - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }, + ), + .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, + ), + ), + ], + ), + ), + destinations: subSpace.children.toDestinations(isDm: isDm), ), ), - ); - }, + ], + onDestinationSelected: (value) { + ref + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(destinationIds[value]); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), ), ), ); diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index 4ea804a..373c833 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/invites.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; @@ -44,15 +45,11 @@ class const RoomSummarySheet( summary.avatarUrl, "", dimension: 64, - fallback: Icon(Icons.numbers), + fallback: Icon(summary.icon), ), ), Expanded( - child: Text( - summary.name ?? summary.canonicalAlias ?? "Unnamed Room", - overflow: .ellipsis, - maxLines: 3, - ), + child: Text(summary.displayName, overflow: .ellipsis, maxLines: 3), ), ], ), diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart index 52a7c16..9741dcb 100644 --- a/lib/widgets/settings/settings_layout.dart +++ b/lib/widgets/settings/settings_layout.dart @@ -12,6 +12,7 @@ import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/pages/settings_category.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/m3e_rail_theme.dart"; import "package:super_sliver_list/super_sliver_list.dart"; /// A settings screen split into categories: a list of them that opens each @@ -170,8 +171,8 @@ class const SettingsLayout({ ) : Row( children: [ - MaterialUiCompatibilityBridge( - child: NavigationRailM3E( + M3ERailTheme( + builder: (_) => NavigationRailM3E( type: .alwaysExpand, trailing: searchBar, scrollable: true, diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 3b799d7..613c13c 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -10,6 +10,7 @@ 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/room_summary_display.dart"; import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/main.dart"; @@ -163,10 +164,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) keyMapper: (room) => room.summary.roomId, values: hierarchy.rooms, ); - final spaceName = - byId[spaceId]?.summary.name ?? - byId[spaceId]?.summary.canonicalAlias ?? - "Unnamed Space"; + final spaceName = byId[spaceId]?.summary.displayName ?? "Unnamed Space"; final children = (byId[spaceId]?.children ?? const .empty()) .map( (child) => switch (byId[child.roomId]) { @@ -197,16 +195,13 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) Builder( builder: (context) { final summary = room.summary; - final name = - summary.name ?? - summary.canonicalAlias ?? - "Unnamed Room"; + final name = summary.displayName; return ListTile( leading: AvatarOrHash( summary.avatarUrl, name, - fallback: const Icon(Icons.numbers), + fallback: Icon(summary.icon), ), title: Text(name, maxLines: 1, overflow: .ellipsis), subtitle: summary.topic == null @@ -239,10 +234,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) builder: (context, ref, _) { final expanded = useState(false); final summary = room.summary; - final name = - summary.name ?? - summary.canonicalAlias ?? - "Unnamed Space"; + final name = summary.displayName; final joined = joinedIds.contains(summary.roomId); return Column( @@ -255,7 +247,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) leading: AvatarOrHash( summary.avatarUrl, name, - fallback: const Icon(Icons.workspaces), + fallback: Icon(summary.icon), ), title: Text(name, maxLines: 1, overflow: .ellipsis), subtitle: summary.topic == null @@ -390,7 +382,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), Expanded( child: Text( - space?.name ?? space?.canonicalAlias ?? "Unnamed Space", + space?.displayName ?? "Unnamed Space", overflow: .ellipsis, maxLines: 3, ),