From 7007c9af897ac780214d28126261db781b9641c3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 2 Nov 2026 12:21:58 -0500 Subject: [PATCH] allow adding / removing rooms from a space Fixes #140 --- lib/controllers/via.dart | 23 ++- .../extensions/build_event_options.dart | 2 +- lib/models/content/content.dart | 2 + lib/models/content/space_child.dart | 20 ++ lib/widgets/add_space_rooms_sheet.dart | 178 ++++++++++++++++++ .../composer/overlays/room_overlay.dart | 4 +- lib/widgets/room_menu_button.dart | 2 +- lib/widgets/space_hierarchy_sheet.dart | 146 ++++++++++++-- 8 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 lib/models/content/space_child.dart create mode 100644 lib/widgets/add_space_rooms_sheet.dart diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart index 8c7f0db..e443572 100644 --- a/lib/controllers/via.dart +++ b/lib/controllers/via.dart @@ -7,9 +7,9 @@ import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; -class ViaController(final Room room) extends Notifier { +class ViaController(final Room room) extends Notifier> { @override - String build() { + ISet build() { final powerLevelUsers = switch (room.state[EventType.powerLevels.type]?[""]) { final rowId? => switch (room.events[rowId]?.content) { @@ -35,7 +35,7 @@ class ViaController(final Room room) extends Notifier { .nonNulls .toIList(); - final servers = [?ref.watch(ClientStateController.provider)?.userId] + return [?ref.watch(ClientStateController.provider)?.userId] .toIList() .addAll(powerLevelUsers) .addAll(joinedMembers) @@ -43,13 +43,16 @@ class ViaController(final Room room) extends Notifier { .whereNoDuplicates() .take(5) .toISet(); - - return servers.isEmpty - ? "" - : "?${servers.map((server) => "via=$server").join("&")}"; } - static final provider = NotifierProvider.family( - ViaController.new, - ); + static final provider = + NotifierProvider.family, Room>( + ViaController.new, + ); +} + +extension ViaQuery on ISet { + /// These servers as a matrix: URI query, or empty if there are none. + String get viaQuery => + isEmpty ? "" : "?${map((server) => "via=$server").join("&")}"; } diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index e96284b..c243478 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -213,7 +213,7 @@ extension BuildEventOptions on Event { ); if (room == null) return; - final vias = ref.read(ViaController.provider(room)); + final vias = ref.read(ViaController.provider(room)).viaQuery; await Clipboard.setData( ClipboardData( diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index aa564e2..72373a4 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -13,6 +13,7 @@ import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/redaction.dart"; import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/space_child.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/history_visibility.dart"; @@ -65,6 +66,7 @@ enum EventType( tombstone("m.room.tombstone", Content.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + spaceChild("m.space.child", SpaceChildContent.fromJson), imagePack("m.room.image_pack", ImagePackContent.fromJson), legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson), diff --git a/lib/models/content/space_child.dart b/lib/models/content/space_child.dart new file mode 100644 index 0000000..07ed5c6 --- /dev/null +++ b/lib/models/content/space_child.dart @@ -0,0 +1,20 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; + +part "space_child.freezed.dart"; +part "space_child.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const SpaceChildContent({ + final IList via = const .empty(), + final String? order, + final bool suggested = false, +}) extends Content with _$SpaceChildContent { + @override + Map toJson() => _$SpaceChildContentToJson(this); + + factory SpaceChildContent.fromJson(Map json) => + _$SpaceChildContentFromJson(json); +} diff --git a/lib/widgets/add_space_rooms_sheet.dart b/lib/widgets/add_space_rooms_sheet.dart new file mode 100644 index 0000000..2f9aeb7 --- /dev/null +++ b/lib/widgets/add_space_rooms_sheet.dart @@ -0,0 +1,178 @@ +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/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_hierarchy.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/space_child.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses rooms to add to the space [spaceId], then adds them. +class const AddSpaceRoomsSheet( + final String spaceId, { + required final String spaceName, + + /// The rooms already in the space, which aren't offered. + required final ISet existing, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(const ISet.empty()); + final error = useState(null); + final resolving = useState(false); + final controller = useTextEditingController(); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => MapEntry(metadata.id, ( + name: metadata.name, + avatar: metadata.avatar, + )), + ), + ), + ), + ); + + String nameOf(String id) => rooms[id]?.name ?? id; + + final suggestions = rooms.entries + .where((entry) => entry.key != spaceId && !existing.contains(entry.key)) + .sortedBy((entry) => entry.value.name?.toLowerCase() ?? "") + .map((entry) => entry.key) + .toIList(); + + void add(String id) { + if (id == spaceId) { + error.value = "A space can't contain itself"; + return; + } + if (existing.contains(id)) { + error.value = "This room is already in the space"; + return; + } + if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id); + controller.clear(); + } + + Future addRoom(String text) async { + if (text.startsWith("!")) return add(text); + + resolving.value = true; + try { + final id = await ref + .read(ClientController.provider.notifier) + .resolveAlias(text); + + if (!context.mounted) return; + + if (id == null) { + error.value = "No room has this alias"; + } else { + add(id); + } + } finally { + if (context.mounted) resolving.value = false; + } + } + + // Runs after the sheet closes, so it can't use [ref] + Future save(ProviderContainer container, ISet roomIds) async { + final client = container.read(ClientController.provider.notifier); + final joined = container.read(RoomsController.provider); + final ownServer = container + .read(ClientStateController.provider) + ?.userId + ?.serverName; + + await Future.wait( + roomIds.map( + (roomId) => client.setState( + .new( + roomId: spaceId, + type: EventType.spaceChild.type, + stateKey: roomId, + content: SpaceChildContent( + via: switch (joined[roomId]) { + final room? => container.read(ViaController.provider(room)), + // Rooms the user isn't in can only be routed through their + // own server + null => ISet([?ownServer]), + }.toIList(), + ), + ), + ), + ), + ); + container.invalidate(SpaceHierarchyController.provider(spaceId)); + } + + return SheetLayout( + title: Text("Add Rooms"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: chosen.value.isEmpty + ? null + : () { + final container = ProviderScope.containerOf(context); + final roomIds = chosen.value; + context.withProgressSnackbar( + "Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...", + () => save(container, roomIds), + ); + Navigator.of(context).pop(); + }, + child: Text("Add"), + ), + ], + children: [ + Text("Choose rooms to add to \"$spaceName\"."), + SearchChooser( + controller: controller, + chosen: chosen.value, + options: suggestions, + nameOf: nameOf, + avatarOf: (id) => AvatarOrHash( + rooms[id]?.avatar, + nameOf(id), + fallback: Icon(Icons.numbers), + ), + subtitleOf: (id) => rooms[id]?.name == null ? null : id, + onAdded: add, + onRemoved: (id) => chosen.value = chosen.value.remove(id), + onSubmitted: (text, topMatch) { + if (text.startsWith(RegExp("[!#]"))) { + addRoom(text).onError(showError); + } else if (topMatch != null) { + add(topMatch); + } else { + error.value = "Enter a room ID or alias"; + } + }, + onChanged: (_) => error.value = null, + label: "Add room", + hint: "Search, or enter a room ID or alias", + removeTooltip: "Remove room", + error: error.value, + loading: resolving.value, + autofocus: true, + ), + ], + ); + } +} diff --git a/lib/widgets/composer/overlays/room_overlay.dart b/lib/widgets/composer/overlays/room_overlay.dart index b5af177..28eb4ab 100644 --- a/lib/widgets/composer/overlays/room_overlay.dart +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -39,7 +39,9 @@ class const RoomOverlay( ? null : Text(room.metadata!.topic!, maxLines: 1), onTap: () { - final vias = ref.watch(ViaController.provider(room)); + final vias = ref + .watch(ViaController.provider(room)) + .viaQuery; addTag( id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", name: diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 358c8d8..4a83bd8 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -42,7 +42,7 @@ final class const RoomMenuButton( if (room != null) ...[ PopupMenuItem( onTap: () async { - final vias = ref.read(ViaController.provider(room!)); + final vias = ref.read(ViaController.provider(room!)).viaQuery; await Clipboard.setData( .new( diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 33a891d..e41d780 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -3,15 +3,20 @@ 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/client.dart"; +import "package:nexus/controllers/power_level.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/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/space_hierarchy.dart"; +import "package:nexus/widgets/add_space_rooms_sheet.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/knock_sheet.dart"; @@ -64,6 +69,60 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) child: const Text("Join"), ); + Widget removeButton( + String spaceId, + String roomId, { + required String name, + required String spaceName, + }) => IconButton( + tooltip: "Remove from space", + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => context.showAdaptiveSheet( + (context) => SheetLayout( + title: const Text("Remove Room"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + context.withProgressSnackbar( + "Removing $name from $spaceName...", + () async { + await container + .read(ClientController.provider.notifier) + .setState( + .new( + roomId: spaceId, + type: EventType.spaceChild.type, + stateKey: roomId, + content: const Content(), + ), + ); + container.invalidate( + SpaceHierarchyController.provider(spaceId), + ); + }, + ); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Remove"), + ), + ], + children: [ + Text( + "Are you sure you want to remove \"$name\" from \"$spaceName\"? Its members will stay in it.", + ), + ], + ), + ), + ); + Widget level( String spaceId, SpaceHierarchy hierarchy, @@ -71,11 +130,20 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ) => HookConsumer( builder: (context, ref, _) { final loadingMore = useState(false); + final canManage = ref.watch( + PowerLevelController.provider( + .state(eventType: .spaceChild, roomId: spaceId), + ), + ); final byId = IMap.fromValues( keyMapper: (room) => room.summary.roomId, values: hierarchy.rooms, ); + final spaceName = + byId[spaceId]?.summary.name ?? + byId[spaceId]?.summary.canonicalAlias ?? + "Unnamed Space"; final children = (byId[spaceId]?.children ?? const .empty()) .map( (child) => switch (byId[child.roomId]) { @@ -126,7 +194,19 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) overflow: .ellipsis, ), onTap: () => showSummary(room, child), - trailing: membershipButton(room, child, name: name), + trailing: Row( + mainAxisSize: .min, + children: [ + membershipButton(room, child, name: name), + if (canManage) + removeButton( + spaceId, + summary.roomId, + name: name, + spaceName: spaceName, + ), + ], + ), ); }, ), @@ -172,6 +252,13 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) child: const Icon(Icons.expand_more), ), membershipButton(room, child, name: name), + if (canManage) + removeButton( + spaceId, + summary.roomId, + name: name, + spaceName: spaceName, + ), ], ), ), @@ -190,6 +277,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), ) .betterWhen( + skipLoadingOnRefresh: true, loading: () => const Loading(dimension: 24), data: (hierarchy) => level( @@ -205,24 +293,43 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ); }, ), - 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"), - ), + if (hierarchy.nextBatch != null || canManage) + Row( + mainAxisAlignment: .center, + spacing: 8, + children: [ + if (hierarchy.nextBatch != null) + 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"), + ), + if (canManage) + TextButton.icon( + onPressed: () => context.showAdaptiveSheet( + (_) => AddSpaceRoomsSheet( + spaceId, + spaceName: spaceName, + existing: (byId[spaceId]?.children ?? const .empty()) + .map((child) => child.roomId) + .toISet(), + ), + ), + icon: const Icon(Icons.add), + label: const Text("Add rooms"), + ), + ], ), ], ); @@ -232,6 +339,7 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) return ref .watch(SpaceHierarchyController.provider(spaceId)) .betterWhen( + skipLoadingOnRefresh: true, data: (hierarchy) { final space = hierarchy.rooms .where((room) => room.summary.roomId == spaceId)