add room directory support

This commit is contained in:
Henry Hiles 2026-11-04 12:43:01 -05:00
commit e382499a2a
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
18 changed files with 868 additions and 245 deletions

View file

@ -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_room_state.dart";
import "package:nexus/models/requests/get_specific_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_space_hierarchy.dart";
import "package:nexus/models/requests/get_room_directory.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart"; import "package:nexus/models/profile_response.dart";
import "package:nexus/models/push_notification.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/update_push_rule.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.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/room_summary.dart";
import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/models/spec_versions_response.dart";
@ -311,6 +313,11 @@ class ClientController(final String? accountId) extends AsyncNotifier<int> {
) async => ) async =>
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson())); .fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
Future<RoomDirectory> getRoomDirectory(
GetRoomDirectoryRequest request,
) async =>
.fromJson(await _sendCommand("get_room_directory", request.toJson()));
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async => Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await _sendCommand("get_room_summary", request.toJson())); .fromJson(await _sendCommand("get_room_summary", request.toJson()));

View file

@ -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<RoomDirectory> {
@override
Future<RoomDirectory> build() => ref
.read(ClientController.provider.notifier)
.getRoomDirectory(.new(server: server));
Future<void> 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<void>();
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomDirectoryController, RoomDirectory, String?>(
RoomDirectoryController.new,
);
}

View file

@ -4,6 +4,12 @@ extension GetLocalpart on String {
/// Whether this looks like a valid Matrix user ID, e.g. `@user:example.com`. /// Whether this looks like a valid Matrix user ID, e.g. `@user:example.com`.
bool get isValidUserId => RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(this); 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 { String? get serverName {
final index = indexOf(":"); final index = indexOf(":");
return index == -1 ? null : substring(index + 1); return index == -1 ? null : substring(index + 1);

View file

@ -17,6 +17,7 @@ extension JoinRoom on BuildContext {
}) async { }) async {
final container = ProviderScope.containerOf(this); final container = ProviderScope.containerOf(this);
final messenger = ScaffoldMessenger.of(this); final messenger = ScaffoldMessenger.of(this);
final navigator = Navigator.of(this);
final roomId = await withProgressSnackbar( final roomId = await withProgressSnackbar(
"Joining $name...", "Joining $name...",
@ -31,6 +32,7 @@ extension JoinRoom on BuildContext {
action: .new( action: .new(
label: "Open", label: "Open",
onPressed: () async { onPressed: () async {
navigator.popUntil((route) => route.isFirst);
await container.focusRoom(roomId); await container.focusRoom(roomId);
container.read(SidebarPageController.provider.notifier).set(.rooms); container.read(SidebarPageController.provider.notifier).set(.rooms);
}, },

View file

@ -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(" · ");
}

View file

@ -2,6 +2,7 @@ import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/helpers/extensions/get_link_info.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"; import "package:nexus/models/content/content.dart";
part "bot_command_description.freezed.dart"; part "bot_command_description.freezed.dart";
@ -181,10 +182,6 @@ enum BotPrimitiveType(final String label, final String expected) {
@JsonValue("event_id") @JsonValue("event_id")
eventId("event", r"a message link or event ID like $abc"); 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}) { Object? parse(String text, {required String roomId}) {
final (:identifier, :eventId, :via) = final (:identifier, :eventId, :via) =
text.linkInfo ?? text.linkInfo ??
@ -206,7 +203,7 @@ enum BotPrimitiveType(final String label, final String expected) {
identifier, identifier,
.roomAlias when identifier.startsWith("#") && identifier.contains(":") => .roomAlias when identifier.startsWith("#") && identifier.contains(":") =>
identifier, identifier,
.serverName when serverNamePattern.hasMatch(text) => text, .serverName when text.isValidServerName => text,
.roomId when identifier.startsWith("!") && eventId == null => { .roomId when identifier.startsWith("!") && eventId == null => {
"type": "room_id", "type": "room_id",
"id": identifier, "id": identifier,

View file

@ -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<String, Object?> toJson() => _$GetRoomDirectoryRequestToJson(this);
factory GetRoomDirectoryRequest.fromJson(Map<String, Object?> json) =>
_$GetRoomDirectoryRequestFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(includeIfNull: false)
class const PublicRoomsFilter({final String? genericSearchTerm})
with _$PublicRoomsFilter {
Map<String, Object?> toJson() => _$PublicRoomsFilterToJson(this);
factory PublicRoomsFilter.fromJson(Map<String, Object?> json) =>
_$PublicRoomsFilterFromJson(json);
}

View file

@ -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<RoomSummary> rooms = const .empty(),
final String? nextBatch,
final int? totalRoomCountEstimate,
}) with _$RoomDirectory {
factory RoomDirectory.fromJson(Map<String, Object?> json) =>
_$RoomDirectoryFromJson(json);
}

View file

@ -1,3 +1,4 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/name_color.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 /// When not sending, read receipts are still sent privately, so that the
/// user's other devices know what has been read. /// user's other devices know what has been read.
final ShowAndSend readReceipts = .showAndSend, final ShowAndSend readReceipts = .showAndSend,
final IList<String> directoryServers = const IListConst([
"matrix.org",
"matrixrooms.info",
]),
}) with _$Settings { }) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this); Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -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<String> 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,
),
),
],
);
}
}

View file

@ -3,8 +3,8 @@ import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/invites.dart"; import "package:nexus/controllers/invites.dart";
import "package:nexus/helpers/extensions/get_localpart.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/main.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/nav_page.dart"; import "package:nexus/models/nav_page.dart";
import "package:nexus/models/room_summary.dart"; import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
@ -120,13 +120,7 @@ class const InviteCard(final RoomSummary invite, {super.key})
invite.avatarUrl, invite.avatarUrl,
name, name,
dimension: 40, dimension: 40,
fallback: invite.isDirect fallback: invite.isDirect ? null : Icon(invite.icon),
? null
: Icon(
invite.roomType == RoomType.space
? Icons.workspaces
: Icons.numbers,
),
), ),
title: Text(name, maxLines: 1, overflow: .ellipsis), title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: Text( subtitle: Text(

View file

@ -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<old_mat.ThemeExtension> 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),
);
},
),
);
}

View file

@ -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<String?>(null);
final servers = IList<String?>([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<void> addServer() async {
final server = await context.showAdaptiveSheet<String>(
(_) => 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<void> 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,
);
},
);
}
}

View file

@ -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<String> 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"),
),
),
],
),
),
),
);
}
}

View file

@ -3,7 +3,6 @@ import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.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:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.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/start_dm_sheet.dart";
import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/divider_widget.dart";
import "package:nexus/widgets/error_dialog.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/pages/settings.dart";
import "package:nexus/widgets/join_sheet.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_summary_sheet.dart";
import "package:nexus/widgets/room_menu_button.dart"; import "package:nexus/widgets/room_menu_button.dart";
import "package:nexus/widgets/space_hierarchy_sheet.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/rooms.dart";
import "package:nexus/controllers/spaces.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 { class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
@override @override
String get title => "Rooms"; String get title => "Rooms";
@ -92,123 +90,112 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
.select((async) => async.requireValue), .select((async) => async.requireValue),
); );
return MaterialUiCompatibilityBridge( return M3ERailTheme(
child: Builder( extensions: [
builder: (context) => old_mat.Theme( NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0),
data: old_mat.Theme.of(context).copyWith( ],
extensions: [ builder: (context) => Container(
NavigationRailM3ETheme( color: NavigationRailTokensAdapter(context).containerColor,
itemCollapsedHeight: 48, padding: .only(top: 16),
itemVerticalGap: 0, child: NavigationRailM3E(
), type: .alwaysCollapse,
], labelBehavior: .alwaysHide,
), scrollable: true,
child: Container( onDestinationSelected: (value) {
color: NavigationRailTokensAdapter(context).containerColor, ref
padding: .only(top: 16), .read(KeyController.provider(KeyController.spaceKey).notifier)
child: NavigationRailM3E( .set(spaces[value].id);
type: .alwaysCollapse, ref
labelBehavior: .alwaysHide, .read(KeyController.provider(KeyController.roomKey).notifier)
scrollable: true, .set(spaces[value].children.firstOrNull?.metadata?.id);
onDestinationSelected: (value) { },
ref sections: [
.read( .new(
KeyController.provider(KeyController.spaceKey).notifier, destinations: spaces.map((space) {
) final notifications = space.allChildRooms.fold(
.set(spaces[value].id); 0,
ref (sum, room) =>
.read( sum + (room.metadata?.unreadNotifications ?? 0),
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( return NavigationRailM3EDestination(
badgeCount: notifications > 0 badgeCount: notifications > 0
? notifications ? notifications
: space.allChildRooms.any( : space.allChildRooms.any(
(room) => room.metadata?.unreadMessages != 0, (room) => room.metadata?.unreadMessages != 0,
) )
? 0 ? 0
: null, : null,
short: true, short: true,
icon: AvatarOrHash( icon: AvatarOrHash(
dimension: 28, dimension: 28,
space.room?.metadata?.avatar, space.room?.metadata?.avatar,
fallback: space.icon == null ? null : Icon(space.icon), fallback: space.icon == null ? null : Icon(space.icon),
space.title, 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(), PopupMenuItem(
const IconButton( onTap: () => context.showAdaptiveSheet(
tooltip: "Explore other rooms (Not yet implemented)", (_) => const CreateRoomSheet(),
onPressed: null, ),
icon: Icon(Icons.explore), child: const ListTile(
title: Text("Create a new room (or space)"),
leading: Icon(Icons.add),
),
), ),
IconButton( PopupMenuItem(
tooltip: "Open settings", onTap: () => context.showAdaptiveSheet(
onPressed: () => showDialog( (_) => const StartDmSheet(),
context: context, ),
builder: (_) => SettingsPage(), 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( body: M3ERailTheme(
child: Builder( extensions: [
builder: (context) { NavigationRailM3ETheme(
final theme = old_mat.Theme.of(context); itemExpandedHeight: 48,
return old_mat.Theme( iconLabelGap: 16,
data: theme.copyWith( sectionHeaderSpacingTop: 0,
extensions: [ ),
NavigationRailM3ETheme( ],
itemExpandedHeight: 48, builder: (context) => Padding(
iconLabelGap: 16, padding: const .only(top: 4),
sectionHeaderSpacingTop: 0, child: NavigationRailM3E(
), fab: selectedSpace.room == null
// Otherwise the FAB label falls back to Roboto ? null
M3ETheme.defaults(theme.colorScheme).copyWith( : .new(
typography: .defaultFor( icon: const Icon(Icons.explore),
theme.brightness, label: "Browse space",
baseOverride: theme.textTheme, tooltip: "Browse and join rooms in this space",
onPressed: () => context.showAdaptiveSheet(
(_) => SpaceHierarchySheet(selectedSpace.id),
), ),
), ),
], expandedWidth: double.infinity,
), scrollable: true,
child: Padding( background: Colors.transparent,
padding: const .only(top: 4), type: .alwaysExpand,
child: NavigationRailM3E( selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)),
fab: selectedSpace.room == null sections: [
? null if (selectedSpace.room case final room?
: .new( when spaceMetadata != null)
icon: const Icon(Icons.explore), .new(
label: "Browse space", destinations: [
tooltip: "Browse and join rooms in this space", room.toDestination(
onPressed: () => context.showAdaptiveSheet( isDm: isDm,
(_) => SpaceHierarchySheet(selectedSpace.id), label: "Space timeline",
), icon: const Icon(Icons.forum),
),
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,
),
),
), ),
], ],
onDestinationSelected: (value) { ),
ref .new(
.read( header: selectedSpace.room == null
KeyController.provider(KeyController.roomKey) ? null
.notifier, : const DividerWidget(Text("Rooms")),
) destinations: selectedSpace.children.toDestinations(isDm: isDm),
.set(destinationIds[value]); ),
if (Navigator.of(context).canPop()) { ...selectedSpace.subSpaces.map(
Navigator.of(context).pop(); (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();
}
},
),
), ),
), ),
); );

View file

@ -7,6 +7,7 @@ import "package:nexus/controllers/invites.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/join_room.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/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
@ -44,15 +45,11 @@ class const RoomSummarySheet(
summary.avatarUrl, summary.avatarUrl,
"", "",
dimension: 64, dimension: 64,
fallback: Icon(Icons.numbers), fallback: Icon(summary.icon),
), ),
), ),
Expanded( Expanded(
child: Text( child: Text(summary.displayName, overflow: .ellipsis, maxLines: 3),
summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
),
), ),
], ],
), ),

View file

@ -12,6 +12,7 @@ import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/pages/settings_category.dart"; import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.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"; import "package:super_sliver_list/super_sliver_list.dart";
/// A settings screen split into categories: a list of them that opens each /// A settings screen split into categories: a list of them that opens each
@ -170,8 +171,8 @@ class const SettingsLayout({
) )
: Row( : Row(
children: [ children: [
MaterialUiCompatibilityBridge( M3ERailTheme(
child: NavigationRailM3E( builder: (_) => NavigationRailM3E(
type: .alwaysExpand, type: .alwaysExpand,
trailing: searchBar, trailing: searchBar,
scrollable: true, scrollable: true,

View file

@ -10,6 +10,7 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_hierarchy.dart"; import "package:nexus/controllers/space_hierarchy.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/join_room.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/show_leave_room_sheet.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -163,10 +164,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
keyMapper: (room) => room.summary.roomId, keyMapper: (room) => room.summary.roomId,
values: hierarchy.rooms, values: hierarchy.rooms,
); );
final spaceName = final spaceName = byId[spaceId]?.summary.displayName ?? "Unnamed Space";
byId[spaceId]?.summary.name ??
byId[spaceId]?.summary.canonicalAlias ??
"Unnamed Space";
final children = (byId[spaceId]?.children ?? const .empty()) final children = (byId[spaceId]?.children ?? const .empty())
.map( .map(
(child) => switch (byId[child.roomId]) { (child) => switch (byId[child.roomId]) {
@ -197,16 +195,13 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
Builder( Builder(
builder: (context) { builder: (context) {
final summary = room.summary; final summary = room.summary;
final name = final name = summary.displayName;
summary.name ??
summary.canonicalAlias ??
"Unnamed Room";
return ListTile( return ListTile(
leading: AvatarOrHash( leading: AvatarOrHash(
summary.avatarUrl, summary.avatarUrl,
name, name,
fallback: const Icon(Icons.numbers), fallback: Icon(summary.icon),
), ),
title: Text(name, maxLines: 1, overflow: .ellipsis), title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: summary.topic == null subtitle: summary.topic == null
@ -239,10 +234,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
builder: (context, ref, _) { builder: (context, ref, _) {
final expanded = useState(false); final expanded = useState(false);
final summary = room.summary; final summary = room.summary;
final name = final name = summary.displayName;
summary.name ??
summary.canonicalAlias ??
"Unnamed Space";
final joined = joinedIds.contains(summary.roomId); final joined = joinedIds.contains(summary.roomId);
return Column( return Column(
@ -255,7 +247,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
leading: AvatarOrHash( leading: AvatarOrHash(
summary.avatarUrl, summary.avatarUrl,
name, name,
fallback: const Icon(Icons.workspaces), fallback: Icon(summary.icon),
), ),
title: Text(name, maxLines: 1, overflow: .ellipsis), title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: summary.topic == null subtitle: summary.topic == null
@ -390,7 +382,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
), ),
Expanded( Expanded(
child: Text( child: Text(
space?.name ?? space?.canonicalAlias ?? "Unnamed Space", space?.displayName ?? "Unnamed Space",
overflow: .ellipsis, overflow: .ellipsis,
maxLines: 3, maxLines: 3,
), ),