add room directory support
This commit is contained in:
parent
033c7f9c05
commit
e382499a2a
18 changed files with 868 additions and 245 deletions
|
|
@ -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<int> {
|
|||
) async =>
|
||||
.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 =>
|
||||
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
||||
|
||||
|
|
|
|||
39
lib/controllers/room_directory.dart
Normal file
39
lib/controllers/room_directory.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
18
lib/helpers/extensions/room_summary_display.dart
Normal file
18
lib/helpers/extensions/room_summary_display.dart
Normal 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(" · ");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
28
lib/models/requests/get_room_directory.dart
Normal file
28
lib/models/requests/get_room_directory.dart
Normal 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);
|
||||
}
|
||||
17
lib/models/room_directory.dart
Normal file
17
lib/models/room_directory.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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<String> directoryServers = const IListConst([
|
||||
"matrix.org",
|
||||
"matrixrooms.info",
|
||||
]),
|
||||
}) with _$Settings {
|
||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
||||
|
||||
|
|
|
|||
54
lib/widgets/add_directory_server_sheet.dart
Normal file
54
lib/widgets/add_directory_server_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
36
lib/widgets/m3e_rail_theme.dart
Normal file
36
lib/widgets/m3e_rail_theme.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
338
lib/widgets/pages/explore.dart
Normal file
338
lib/widgets/pages/explore.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/widgets/room_directory_card.dart
Normal file
130
lib/widgets/room_directory_card.dart
Normal 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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 +90,11 @@ 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(
|
||||
return M3ERailTheme(
|
||||
extensions: [
|
||||
NavigationRailM3ETheme(
|
||||
itemCollapsedHeight: 48,
|
||||
itemVerticalGap: 0,
|
||||
),
|
||||
NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
builder: (context) => Container(
|
||||
color: NavigationRailTokensAdapter(context).containerColor,
|
||||
padding: .only(top: 16),
|
||||
child: NavigationRailM3E(
|
||||
|
|
@ -112,14 +103,10 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
|
|||
scrollable: true,
|
||||
onDestinationSelected: (value) {
|
||||
ref
|
||||
.read(
|
||||
KeyController.provider(KeyController.spaceKey).notifier,
|
||||
)
|
||||
.read(KeyController.provider(KeyController.spaceKey).notifier)
|
||||
.set(spaces[value].id);
|
||||
ref
|
||||
.read(
|
||||
KeyController.provider(KeyController.roomKey).notifier,
|
||||
)
|
||||
.read(KeyController.provider(KeyController.roomKey).notifier)
|
||||
.set(spaces[value].children.firstOrNull?.metadata?.id);
|
||||
},
|
||||
sections: [
|
||||
|
|
@ -193,10 +180,12 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
|
|||
icon: const Icon(Icons.add),
|
||||
),
|
||||
const AccountSwitcher(),
|
||||
const IconButton(
|
||||
tooltip: "Explore other rooms (Not yet implemented)",
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.explore),
|
||||
IconButton(
|
||||
tooltip: "Explore other rooms",
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ExplorePage()),
|
||||
),
|
||||
icon: const Icon(Icons.explore),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Open settings",
|
||||
|
|
@ -211,8 +200,6 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -254,28 +241,15 @@ 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(
|
||||
body: M3ERailTheme(
|
||||
extensions: [
|
||||
NavigationRailM3ETheme(
|
||||
itemExpandedHeight: 48,
|
||||
iconLabelGap: 16,
|
||||
sectionHeaderSpacingTop: 0,
|
||||
),
|
||||
// Otherwise the FAB label falls back to Roboto
|
||||
M3ETheme.defaults(theme.colorScheme).copyWith(
|
||||
typography: .defaultFor(
|
||||
theme.brightness,
|
||||
baseOverride: theme.textTheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
builder: (context) => Padding(
|
||||
padding: const .only(top: 4),
|
||||
child: NavigationRailM3E(
|
||||
fab: selectedSpace.room == null
|
||||
|
|
@ -309,9 +283,7 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
|
|||
header: selectedSpace.room == null
|
||||
? null
|
||||
: const DividerWidget(Text("Rooms")),
|
||||
destinations: selectedSpace.children.toDestinations(
|
||||
isDm: isDm,
|
||||
),
|
||||
destinations: selectedSpace.children.toDestinations(isDm: isDm),
|
||||
),
|
||||
...selectedSpace.subSpaces.map(
|
||||
(subSpace) => NavigationRailM3ESection(
|
||||
|
|
@ -323,14 +295,12 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
|
|||
if (subSpace.room.metadata?.avatar != null)
|
||||
AvatarOrHash(
|
||||
subSpace.room.metadata?.avatar,
|
||||
subSpace.room.metadata?.name ??
|
||||
"Unnamed Room",
|
||||
subSpace.room.metadata?.name ?? "Unnamed Room",
|
||||
dimension: 16,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
subSpace.room.metadata?.name ??
|
||||
"Unnamed Space",
|
||||
subSpace.room.metadata?.name ?? "Unnamed Space",
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
|
|
@ -338,18 +308,13 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
destinations: subSpace.children.toDestinations(
|
||||
isDm: isDm,
|
||||
),
|
||||
destinations: subSpace.children.toDestinations(isDm: isDm),
|
||||
),
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (value) {
|
||||
ref
|
||||
.read(
|
||||
KeyController.provider(KeyController.roomKey)
|
||||
.notifier,
|
||||
)
|
||||
.read(KeyController.provider(KeyController.roomKey).notifier)
|
||||
.set(destinationIds[value]);
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
|
|
@ -357,9 +322,6 @@ class const RoomsPane({super.key}) extends ConsumerWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue