Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

Show space hierarchies

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

View file

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