allow adding / removing rooms from a space

Fixes #140
This commit is contained in:
Henry Hiles 2026-11-02 12:21:58 -05:00
commit 7007c9af89
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 345 additions and 32 deletions

View file

@ -7,9 +7,9 @@ import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class ViaController(final Room room) extends Notifier<String> { class ViaController(final Room room) extends Notifier<ISet<String>> {
@override @override
String build() { ISet<String> build() {
final powerLevelUsers = final powerLevelUsers =
switch (room.state[EventType.powerLevels.type]?[""]) { switch (room.state[EventType.powerLevels.type]?[""]) {
final rowId? => switch (room.events[rowId]?.content) { final rowId? => switch (room.events[rowId]?.content) {
@ -35,7 +35,7 @@ class ViaController(final Room room) extends Notifier<String> {
.nonNulls .nonNulls
.toIList(); .toIList();
final servers = [?ref.watch(ClientStateController.provider)?.userId] return [?ref.watch(ClientStateController.provider)?.userId]
.toIList() .toIList()
.addAll(powerLevelUsers) .addAll(powerLevelUsers)
.addAll(joinedMembers) .addAll(joinedMembers)
@ -43,13 +43,16 @@ class ViaController(final Room room) extends Notifier<String> {
.whereNoDuplicates() .whereNoDuplicates()
.take(5) .take(5)
.toISet(); .toISet();
return servers.isEmpty
? ""
: "?${servers.map((server) => "via=$server").join("&")}";
} }
static final provider = NotifierProvider.family<ViaController, String, Room>( static final provider =
ViaController.new, NotifierProvider.family<ViaController, ISet<String>, Room>(
); ViaController.new,
);
}
extension ViaQuery on ISet<String> {
/// These servers as a matrix: URI query, or empty if there are none.
String get viaQuery =>
isEmpty ? "" : "?${map((server) => "via=$server").join("&")}";
} }

View file

@ -213,7 +213,7 @@ extension BuildEventOptions on Event {
); );
if (room == null) return; if (room == null) return;
final vias = ref.read(ViaController.provider(room)); final vias = ref.read(ViaController.provider(room)).viaQuery;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(

View file

@ -13,6 +13,7 @@ import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/redaction.dart"; import "package:nexus/models/content/redaction.dart";
import "package:nexus/models/content/server_acl.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/topic.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
@ -65,6 +66,7 @@ enum EventType(
tombstone("m.room.tombstone", Content.fromJson), tombstone("m.room.tombstone", Content.fromJson),
reaction("m.reaction", ReactionContent.fromJson), reaction("m.reaction", ReactionContent.fromJson),
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
spaceChild("m.space.child", SpaceChildContent.fromJson),
imagePack("m.room.image_pack", ImagePackContent.fromJson), imagePack("m.room.image_pack", ImagePackContent.fromJson),
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson), pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson),

View file

@ -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<String> via = const .empty(),
final String? order,
final bool suggested = false,
}) extends Content with _$SpaceChildContent {
@override
Map<String, Object?> toJson() => _$SpaceChildContentToJson(this);
factory SpaceChildContent.fromJson(Map<String, Object?> json) =>
_$SpaceChildContentFromJson(json);
}

View file

@ -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<String> existing,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(const ISet<String>.empty());
final error = useState<String?>(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<void> 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<void> save(ProviderContainer container, ISet<String> 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,
),
],
);
}
}

View file

@ -39,7 +39,9 @@ class const RoomOverlay(
? null ? null
: Text(room.metadata!.topic!, maxLines: 1), : Text(room.metadata!.topic!, maxLines: 1),
onTap: () { onTap: () {
final vias = ref.watch(ViaController.provider(room)); final vias = ref
.watch(ViaController.provider(room))
.viaQuery;
addTag( addTag(
id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)",
name: name:

View file

@ -42,7 +42,7 @@ final class const RoomMenuButton(
if (room != null) ...[ if (room != null) ...[
PopupMenuItem( PopupMenuItem(
onTap: () async { onTap: () async {
final vias = ref.read(ViaController.provider(room!)); final vias = ref.read(ViaController.provider(room!)).viaQuery;
await Clipboard.setData( await Clipboard.setData(
.new( .new(

View file

@ -3,15 +3,20 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.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:nexus/controllers/client.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/rooms.dart"; 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/show_adaptive_sheet.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/show_leave_room_sheet.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/space_hierarchy.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/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/knock_sheet.dart";
@ -64,6 +69,60 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
child: const Text("Join"), 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( Widget level(
String spaceId, String spaceId,
SpaceHierarchy hierarchy, SpaceHierarchy hierarchy,
@ -71,11 +130,20 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
) => HookConsumer( ) => HookConsumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final loadingMore = useState(false); final loadingMore = useState(false);
final canManage = ref.watch(
PowerLevelController.provider(
.state(eventType: .spaceChild, roomId: spaceId),
),
);
final byId = IMap.fromValues( final byId = IMap.fromValues(
keyMapper: (room) => room.summary.roomId, keyMapper: (room) => room.summary.roomId,
values: hierarchy.rooms, values: hierarchy.rooms,
); );
final spaceName =
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]) {
@ -126,7 +194,19 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
overflow: .ellipsis, overflow: .ellipsis,
), ),
onTap: () => showSummary(room, child), 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), child: const Icon(Icons.expand_more),
), ),
membershipButton(room, child, name: name), 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( .betterWhen(
skipLoadingOnRefresh: true,
loading: () => loading: () =>
const Loading(dimension: 24), const Loading(dimension: 24),
data: (hierarchy) => level( data: (hierarchy) => level(
@ -205,24 +293,43 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
); );
}, },
), ),
if (hierarchy.nextBatch != null) if (hierarchy.nextBatch != null || canManage)
Center( Row(
child: TextButton( mainAxisAlignment: .center,
onPressed: loadingMore.value spacing: 8,
? null children: [
: () async { if (hierarchy.nextBatch != null)
loadingMore.value = true; TextButton(
await ref onPressed: loadingMore.value
.read( ? null
SpaceHierarchyController.provider(spaceId) : () async {
.notifier, loadingMore.value = true;
) await ref
.loadMore() .read(
.onError(showError); SpaceHierarchyController.provider(spaceId)
if (context.mounted) loadingMore.value = false; .notifier,
}, )
child: const Text("Load more"), .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 return ref
.watch(SpaceHierarchyController.provider(spaceId)) .watch(SpaceHierarchyController.provider(spaceId))
.betterWhen( .betterWhen(
skipLoadingOnRefresh: true,
data: (hierarchy) { data: (hierarchy) {
final space = hierarchy.rooms final space = hierarchy.rooms
.where((room) => room.summary.roomId == spaceId) .where((room) => room.summary.roomId == spaceId)