diff --git a/gomuks b/gomuks index a59201a..9e6254b 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit a59201a03b0f3e14e0ecf1ce2c964e6f1115ad4f +Subproject commit 9e6254b703a841d1e9ee64dbaf90d5a1b798696e diff --git a/lib/controllers/capabilities.dart b/lib/controllers/capabilities.dart new file mode 100644 index 0000000..3b7a76f --- /dev/null +++ b/lib/controllers/capabilities.dart @@ -0,0 +1,14 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/capabilities.dart"; + +class CapabilitiesController extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getCapabilities(); + + static final provider = + AsyncNotifierProvider( + CapabilitiesController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 766fe1e..361b4e6 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -488,11 +488,21 @@ class ClientController(final String? accountId) extends AsyncNotifier { }))["visibility"] == "public"; - Future setRoomPublished(String roomId, bool published) => - _sendCommand("publish_room", { - "room_id": roomId, - "visibility": published ? "public" : "private", - }); + Future setRoomPublished(String roomId, bool published) => _sendCommand( + "publish_room", + {"room_id": roomId, "visibility": published ? "public" : "private"}, + ); + + Future upgradeRoom( + String roomId, + String version, { + ISet additionalCreators = const .empty(), + }) async => (await _sendCommand("upgrade_room", { + "room_id": roomId, + "new_version": version, + if (additionalCreators.isNotEmpty) + "additional_creators": additionalCreators.toList(), + }))["replacement_room"]; /// The room [alias] points to, or null if it doesn't exist. Future resolveAlias(String alias) async { diff --git a/lib/controllers/room_power_levels.dart b/lib/controllers/room_power_levels.dart index e33c436..7001eec 100644 --- a/lib/controllers/room_power_levels.dart +++ b/lib/controllers/room_power_levels.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/power_levels.dart"; @@ -26,8 +27,7 @@ class RoomPowerLevelsController(final String roomId) _ => null, }; // Creators only outrank everyone from room version 12, earlier ones just have a power level - final creatorsPrivileged = - (int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12; + final creatorsPrivileged = (create?.roomVersion).versionAtLeast(12); return .new( content: switch (stateEvent(.powerLevels)?.content) { diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 7136e05..bf7159a 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -14,6 +14,7 @@ import "package:nexus/controllers/room_published.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/helpers/extensions/join_rule_name.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/main.dart"; @@ -29,6 +30,7 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/tombstone.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/join_rule.dart"; import "package:nexus/models/power_level_permission.dart"; @@ -46,6 +48,7 @@ import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_tile.dart"; import "package:nexus/widgets/settings/text_setting_tile.dart"; +import "package:nexus/widgets/settings/upgrade_room_sheet.dart"; class RoomSettingsSectionsController(final String roomId) extends AsyncNotifier>> { @@ -122,18 +125,18 @@ class RoomSettingsSectionsController(final String roomId) ?canonicalAlias.alias, ]).toISet(); - final roomVersion = switch (stateContent(.create)) { - CreateContent(:final roomVersion) => int.tryParse(roomVersion), + final versionName = switch (stateContent(.create)) { + CreateContent(:final roomVersion) => roomVersion, _ => null, }; - bool versionAtLeast(int version) => - roomVersion == null || roomVersion >= version; + final canUpgrade = + canSet(.tombstone) && stateContent(.tombstone) is! TombstoneContent; final joinRuleOptions = [ JoinRule.public, JoinRule.invite, - if (versionAtLeast(7)) JoinRule.knock, - if (versionAtLeast(8)) JoinRule.restricted, - if (versionAtLeast(10)) JoinRule.knockRestricted, + if (versionName.versionAtLeast(7)) JoinRule.knock, + if (versionName.versionAtLeast(8)) JoinRule.restricted, + if (versionName.versionAtLeast(10)) JoinRule.knockRestricted, ]; bool isRestricted(JoinRule rule) => rule == .restricted || rule == .knockRestricted; @@ -577,9 +580,8 @@ class RoomSettingsSectionsController(final String roomId) ? (value) => saving.run( () => ref .read( - RoomPublishedController.provider( - roomId, - ).notifier, + RoomPublishedController.provider(roomId) + .notifier, ) .set(value) .onError(showError), @@ -589,6 +591,52 @@ class RoomSettingsSectionsController(final String roomId) }, ), ), + .new( + title: "Room Version", + description: "The rules the room follows, set when it's created.", + icon: Icons.upgrade, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text( + versionName == null + ? description + : "Version $versionName", + ), + trailing: TextButton( + onPressed: + canUpgrade && versionName != null && !saving.active + ? () => context.showAdaptiveSheet( + (_) => UpgradeRoomSheet( + roomId: roomId, + currentVersion: versionName, + creators: roomPowerLevels.creators.remove( + userId ?? "", + ), + userId: userId, + onSave: (version, creators) => saving.run( + () => client + .upgradeRoom( + roomId, + version, + additionalCreators: creators, + ) + .then((_) {}) + .onError(showError), + ), + ), + ) + : null, + child: Text("Upgrade"), + ), + ); + }, + ), + ), ]), ), .new( diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 01635d9..25675e2 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -8,6 +8,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:nexus/controllers/accounts.dart"; import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/capabilities.dart"; import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/client_state.dart"; @@ -215,9 +216,9 @@ class UnifiedPushController extends AsyncNotifier { state = .loading(); try { await Future(() async { - final capabilities = await ref - .read(ClientController.provider.notifier) - .getCapabilities(); + final capabilities = await ref.read( + CapabilitiesController.provider.future, + ); if (capabilities.webpush?.vapid == null) { throw UnsupportedError( diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart index 03ce3b0..6dd1cf7 100644 --- a/lib/controllers/unified_push_allowed.dart +++ b/lib/controllers/unified_push_allowed.dart @@ -1,7 +1,7 @@ import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/capabilities.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; class UnifiedPushAllowedController extends AsyncNotifier { @@ -19,9 +19,9 @@ class UnifiedPushAllowedController extends AsyncNotifier { : "Your platform is not currently supported by UnifiedPush"}."; } - final capabilities = await ref - .read(ClientController.provider.notifier) - .getCapabilities(); + final capabilities = await ref.watch( + CapabilitiesController.provider.future, + ); if (capabilities.webpush?.vapid == null) { return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus."; diff --git a/lib/helpers/extensions/room_version_at_least.dart b/lib/helpers/extensions/room_version_at_least.dart new file mode 100644 index 0000000..f66d573 --- /dev/null +++ b/lib/helpers/extensions/room_version_at_least.dart @@ -0,0 +1,8 @@ +extension RoomVersionAtLeast on String? { + /// Whether this room version is [version] or newer. Unknown ones, like + /// unstable versions, are assumed to be new enough. + bool versionAtLeast(int version) => switch (int.tryParse(this ?? "")) { + final number? => number >= version, + null => true, + }; +} diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart index 6830170..cdf86b7 100644 --- a/lib/models/capabilities.dart +++ b/lib/models/capabilities.dart @@ -1,3 +1,4 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; part "capabilities.freezed.dart"; @@ -7,6 +8,7 @@ part "capabilities.g.dart"; @JsonSerializable() class const Capabilities({ @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, + @JsonKey(name: "m.room_versions") final RoomVersions? roomVersions, }) with _$Capabilities { Map toJson() => _$CapabilitiesToJson(this); @@ -23,3 +25,30 @@ class const WebPush({required final bool enabled, final String? vapid}) factory WebPush.fromJson(Map json) => _$WebPushFromJson(json); } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomVersions({ + @JsonKey(name: "default") required final String defaultVersion, + @JsonKey(fromJson: RoomVersionStability.mapFromJson) + required final IMap available, +}) with _$RoomVersions { + Map toJson() => _$RoomVersionsToJson(this); + + factory RoomVersions.fromJson(Map json) => + _$RoomVersionsFromJson(json); +} + +enum RoomVersionStability { + stable, + unstable, + unknown; + + static IMap mapFromJson( + Map json, + ) => IMap.fromJson( + json, + (version) => version as String, + (stability) => values.asNameMap()[stability] ?? unknown, + ); +} diff --git a/lib/widgets/settings/add_users_power_level_sheet.dart b/lib/widgets/settings/add_users_power_level_sheet.dart index ec865f4..b406414 100644 --- a/lib/widgets/settings/add_users_power_level_sheet.dart +++ b/lib/widgets/settings/add_users_power_level_sheet.dart @@ -1,15 +1,9 @@ -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:material_adaptive_sheets/material_adaptive_sheets.dart"; -import "package:nexus/controllers/members.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/member_chooser.dart"; import "package:nexus/widgets/settings/power_level_field.dart"; -import "package:nexus/widgets/settings/search_chooser.dart"; /// Chooses users of [roomId] to give a power level of their own. class const AddUsersPowerLevelSheet({ @@ -22,43 +16,16 @@ class const AddUsersPowerLevelSheet({ required final double ownLevel, required final void Function(ISet users, int level) onSave, super.key, -}) extends HookConsumerWidget { +}) extends HookWidget { @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final chosen = useState(const ISet.empty()); - final error = useState(null); - final searchController = useTextEditingController(); final levelController = useTextEditingController(text: "0"); final level = PowerLevelField.levelFrom( useValueListenable(levelController).text, ownLevel, ); - final members = IMap.fromEntries( - (ref.watch(MembersController.provider(roomId)).value ?? - const ISet.empty()) - .map( - (event) => switch ((event.stateKey, event.content)) { - (final userId?, final MembershipContent content) - when content.status == .join || content.status == .invite => - MapEntry(userId, content), - _ => null, - }, - ) - .nonNulls, - ); - - String nameOf(String id) => members[id]?.displayName ?? id.localpart; - - void add(String id) { - if (existing.contains(id)) { - error.value = "Already has a power level"; - return; - } - chosen.value = chosen.value.add(id); - searchController.clear(); - } - final valid = chosen.value.isNotEmpty && level != null; void save() { Navigator.of(context).pop(); @@ -72,33 +39,15 @@ class const AddUsersPowerLevelSheet({ TextButton(onPressed: valid ? save : null, child: Text("Save")), ], children: [ - SearchChooser( - controller: searchController, + MemberChooser( + roomId: roomId, chosen: chosen.value, - options: members.keys - .where((id) => !existing.contains(id)) - .sortedBy((id) => nameOf(id).toLowerCase()) - .toIList(), - nameOf: nameOf, - avatarOf: (id) => - AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40), - subtitleOf: (id) => id, - onAdded: add, + excluded: existing, + excludedError: "Already has a power level", + onAdded: (id) => chosen.value = chosen.value.add(id), onRemoved: (id) => chosen.value = chosen.value.remove(id), - onSubmitted: (text, topMatch) { - if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) { - add(text); - } else if (topMatch != null) { - add(topMatch); - } else { - error.value = "Enter a user ID"; - } - }, - onChanged: (_) => error.value = null, label: "Add user", - hint: "Search members, or enter a user ID", removeTooltip: "Remove user", - error: error.value, autofocus: true, ), PowerLevelField( diff --git a/lib/widgets/settings/member_chooser.dart b/lib/widgets/settings/member_chooser.dart new file mode 100644 index 0000000..18ed2e2 --- /dev/null +++ b/lib/widgets/settings/member_chooser.dart @@ -0,0 +1,91 @@ +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/members.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; + +/// The [chosen] users, above a field searching [roomId]'s members, or taking a user ID, to choose more. +class const MemberChooser({ + required final String roomId, + required final ISet chosen, + required final void Function(String userId) onAdded, + required final void Function(String userId) onRemoved, + required final String label, + required final String removeTooltip, + + /// Users who can't be chosen, so aren't suggested. + final ISet excluded = const .empty(), + + /// Shown when trying to choose one of the [excluded] users. + final String excludedError = "Can't be chosen", + final String? emptyText, + final bool autofocus = false, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final error = useState(null); + final searchController = useTextEditingController(); + + final members = IMap.fromEntries( + (ref.watch(MembersController.provider(roomId)).value ?? + const ISet.empty()) + .map( + (event) => switch ((event.stateKey, event.content)) { + (final userId?, final MembershipContent content) + when content.status == .join || content.status == .invite => + MapEntry(userId, content), + _ => null, + }, + ) + .nonNulls, + ); + + String nameOf(String id) => members[id]?.displayName ?? id.localpart; + + void add(String id) { + if (excluded.contains(id)) { + error.value = excludedError; + return; + } + onAdded(id); + searchController.clear(); + } + + return SearchChooser( + controller: searchController, + chosen: chosen, + options: members.keys + .where((id) => !excluded.contains(id)) + .sortedBy((id) => nameOf(id).toLowerCase()) + .toIList(), + nameOf: nameOf, + avatarOf: (id) => + AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40), + subtitleOf: (id) => id, + onAdded: add, + onRemoved: onRemoved, + onSubmitted: (text, topMatch) { + if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) { + add(text); + } else if (topMatch != null) { + add(topMatch); + } else { + error.value = "Enter a user ID"; + } + }, + onChanged: (_) => error.value = null, + label: label, + hint: "Search members, or enter a user ID", + removeTooltip: removeTooltip, + emptyText: emptyText, + error: error.value, + autofocus: autofocus, + ); + } +} diff --git a/lib/widgets/settings/upgrade_room_sheet.dart b/lib/widgets/settings/upgrade_room_sheet.dart new file mode 100644 index 0000000..a54325b --- /dev/null +++ b/lib/widgets/settings/upgrade_room_sheet.dart @@ -0,0 +1,100 @@ +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:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/capabilities.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/member_chooser.dart"; + +/// Chooses a room version to upgrade [roomId] to, and its additional creators if the version has them. +class const UpgradeRoomSheet({ + required final String roomId, + required final String currentVersion, + + /// The room's current creators, other than the user, to keep in the new room. + required final ISet creators, + required final String? userId, + required final void Function(String version, ISet creators) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final roomVersions = ref + .watch(CapabilitiesController.provider) + .whenData((capabilities) => capabilities.roomVersions); + final chosen = useState(null); + final chosenCreators = useState(creators); + + final versions = roomVersions.value; + final version = chosen.value ?? versions?.defaultVersion; + + final valid = version != null; + final withCreators = valid && version.versionAtLeast(12); + void save() { + Navigator.of(context).pop(); + onSave(version!, withCreators ? chosenCreators.value : const .empty()); + } + + return SheetLayout( + title: Text("Upgrade Room"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Upgrade")), + ], + children: [ + Text( + "Upgrading creates a new room with the chosen version and closes this one, pointing its members to the new room. Members will have to join the new room themselves.", + ), + roomVersions.betterWhen( + data: (versions) => versions == null + ? Text( + "Your homeserver didn't list the room versions it supports.", + ) + : DialogListTile( + icon: Icon(Icons.upgrade), + title: "Room Version", + subtitle: Text("The version the new room will have."), + value: version, + options: versions.available.keys.sortedBy( + (option) => int.tryParse(option) ?? double.infinity, + ), + getName: (option) => switch ([ + if (option == currentVersion) "current", + if (option == versions.defaultVersion) "default", + if (versions.available[option] == .unstable) "unstable", + ]) { + final labels when labels.isNotEmpty => + "$option (${labels.join(", ")})", + _ => option, + }, + onChanged: (value) => chosen.value = value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: withCreators + ? MemberChooser( + roomId: roomId, + chosen: chosenCreators.value, + excluded: ISet([?userId]), + excludedError: "You'll be a creator already", + onAdded: (id) => + chosenCreators.value = chosenCreators.value.add(id), + onRemoved: (id) => + chosenCreators.value = chosenCreators.value.remove(id), + label: "Add creator", + removeTooltip: "Remove creator", + emptyText: "Creators outrank everyone in the new room, and can't be demoted. You'll be one, and can add others.", + ) + : const SizedBox(width: .infinity), + ), + ], + ); + } +}