From b0dd452926c311f0d1e46f857c6e32eff7c4fa24 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 22 Oct 2026 15:29:02 -0400 Subject: [PATCH] add power level room settings --- lib/controllers/members_grouped.dart | 27 +- lib/controllers/power_level.dart | 34 +- lib/controllers/power_levels_saving.dart | 20 ++ lib/controllers/room_creators.dart | 30 -- lib/controllers/room_power_levels.dart | 59 ++++ lib/controllers/room_settings_sections.dart | 331 ++++++++++++++++++ lib/models/content/power_levels.dart | 2 +- lib/models/room_power_levels.dart | 11 + .../settings/add_users_power_level_sheet.dart | 112 ++++++ lib/widgets/settings/allowed_rooms_sheet.dart | 132 ++----- .../settings/power_level_entry_sheet.dart | 55 +++ lib/widgets/settings/power_level_field.dart | 48 +++ lib/widgets/settings/power_level_tile.dart | 120 +++++++ lib/widgets/settings/search_chooser.dart | 142 ++++++++ 14 files changed, 944 insertions(+), 179 deletions(-) create mode 100644 lib/controllers/power_levels_saving.dart delete mode 100644 lib/controllers/room_creators.dart create mode 100644 lib/controllers/room_power_levels.dart create mode 100644 lib/models/room_power_levels.dart create mode 100644 lib/widgets/settings/add_users_power_level_sheet.dart create mode 100644 lib/widgets/settings/power_level_entry_sheet.dart create mode 100644 lib/widgets/settings/power_level_field.dart create mode 100644 lib/widgets/settings/power_level_tile.dart create mode 100644 lib/widgets/settings/search_chooser.dart diff --git a/lib/controllers/members_grouped.dart b/lib/controllers/members_grouped.dart index f903ec4..a668896 100644 --- a/lib/controllers/members_grouped.dart +++ b/lib/controllers/members_grouped.dart @@ -1,44 +1,27 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_by_status.dart"; -import "package:nexus/controllers/room_creators.dart"; -import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/models/configs/members_by_status.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/event.dart"; class MembersGroupedController(final MembersByStatusConfig config) extends AsyncNotifier>>> { @override Future>>> build() async { - final room = ref.watch( - RoomsController.provider.select((value) => value[config.roomId]), + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), ); - final roomCreators = room == null - ? null - : ref.watch((RoomCreatorsController.provider(room))); - - final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""]; - final powerLevelsEvent = powerLevelsRowId == null - ? null - : room?.events[powerLevelsRowId]; - - final content = switch (powerLevelsEvent?.content) { - PowerLevelsContent content => content, - _ => PowerLevelsContent(), - }; - final members = await ref.watch( MembersByStatusController.provider(config).future, ); return members .fold>>(.new(), (result, event) { - final groupKey = roomCreators?.contains(event.stateKey!) == true + final groupKey = powerLevels.creators.contains(event.stateKey!) ? null - : content.users[event.stateKey!] ?? content.usersDefault; + : powerLevels.levelOf(event.stateKey!).toInt(); return result.update( groupKey, diff --git a/lib/controllers/power_level.dart b/lib/controllers/power_level.dart index 7111e38..6fe9d68 100644 --- a/lib/controllers/power_level.dart +++ b/lib/controllers/power_level.dart @@ -1,10 +1,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/room_creators.dart"; -import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/models/configs/power_level.dart"; import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/power_levels.dart"; class PowerLevelController(final PowerLevelConfig config) extends Notifier { @@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config) ); } - final room = ref.watch( - RoomsController.provider.select((value) => value[config.roomId]), + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), ); - - final roomCreators = room == null - ? null - : ref.watch(RoomCreatorsController.provider(room)); - - final eventRowId = room?.state[EventType.powerLevels.type]?[""]; - - final event = eventRowId == null ? null : room?.events[eventRowId]; - final content = event?.content is PowerLevelsContent - ? event!.content - : PowerLevelsContent(); + final content = powerLevels.content; final user = ref.watch( ClientStateController.provider.select((value) => value?.userId), ); - if (user == null || content is! PowerLevelsContent) return false; + if (user == null) return false; - double powerLevelOf(String userId) => roomCreators?.contains(userId) == true - ? double.infinity - : (content.users[userId] ?? content.usersDefault).toDouble(); - - final userLevel = powerLevelOf(user); + final userLevel = powerLevels.levelOf(user); return switch (config) { EventPowerLevelConfig(:final eventType) => @@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config) .invite => userLevel >= content.invite, .kick => - userLevel >= content.kick && userLevel > powerLevelOf(targetUser), + userLevel >= content.kick && + userLevel > powerLevels.levelOf(targetUser), .ban => - userLevel >= content.ban && userLevel > powerLevelOf(targetUser), + userLevel >= content.ban && + userLevel > powerLevels.levelOf(targetUser), .unban => userLevel >= content.ban, }, diff --git a/lib/controllers/power_levels_saving.dart b/lib/controllers/power_levels_saving.dart new file mode 100644 index 0000000..d20d326 --- /dev/null +++ b/lib/controllers/power_levels_saving.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class PowerLevelsSavingController(final String roomId) extends Notifier { + @override + bool build() => false; + + Future run(Future Function() action) async { + state = true; + try { + await action(); + } finally { + if (ref.mounted) state = false; + } + } + + static final provider = NotifierProvider.autoDispose + .family( + PowerLevelsSavingController.new, + ); +} diff --git a/lib/controllers/room_creators.dart b/lib/controllers/room_creators.dart deleted file mode 100644 index 09acdd7..0000000 --- a/lib/controllers/room_creators.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/create.dart"; -import "package:nexus/models/room.dart"; - -class RoomCreatorsController(final Room room) extends Notifier> { - @override - IList build() { - final createRowId = room.state[EventType.create.type]?[""]; - final createEvent = createRowId == null ? null : room.events[createRowId]; - - if (createEvent == null) return .new(); - - final createEventContent = switch (createEvent.content) { - CreateContent content => content, - _ => null, - }; - - return switch (createEventContent?.additionalCreatorIds) { - IList creators => creators.add(createEvent.sender), - _ => .new([createEvent.sender]), - }; - } - - static final provider = - NotifierProvider.family, Room>( - RoomCreatorsController.new, - ); -} diff --git a/lib/controllers/room_power_levels.dart b/lib/controllers/room_power_levels.dart new file mode 100644 index 0000000..e33c436 --- /dev/null +++ b/lib/controllers/room_power_levels.dart @@ -0,0 +1,59 @@ +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/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room_power_levels.dart"; + +class RoomPowerLevelsController(final String roomId) + extends Notifier { + @override + RoomPowerLevels build() { + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) { + final rowId? => room?.events[rowId], + null => null, + }; + + final createEvent = stateEvent(.create); + final create = switch (createEvent?.content) { + final CreateContent content => content, + _ => null, + }; + // Creators only outrank everyone from room version 12, earlier ones just have a power level + final creatorsPrivileged = + (int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12; + + return .new( + content: switch (stateEvent(.powerLevels)?.content) { + final PowerLevelsContent content => content, + // Until there are power levels, creators have 100 + _ => PowerLevelsContent( + users: switch (createEvent) { + final createEvent? when !creatorsPrivileged => .new({ + createEvent.sender: 100, + }), + _ => const .empty(), + }, + ), + }, + creators: switch (createEvent) { + final createEvent? when creatorsPrivileged => + (create?.additionalCreatorIds ?? const .empty()) + .add(createEvent.sender) + .toISet(), + _ => const .empty(), + }, + ); + } + + static final provider = NotifierProvider.autoDispose + .family( + RoomPowerLevelsController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index b847e55..74f0d9f 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -3,8 +3,11 @@ 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/local_aliases.dart"; import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -15,18 +18,26 @@ import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; +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/topic.dart"; import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/setting.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; 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"; class RoomSettingsSectionsController(final String roomId) @@ -69,6 +80,9 @@ class RoomSettingsSectionsController(final String roomId) final canSetJoinRules = canSet(.joinRules); final canSetHistoryVisibility = canSet(.historyVisibility); final canSetAliases = canSet(.canonicalAlias); + final canSetPowerLevels = + canSet(.powerLevels) && + !ref.watch(PowerLevelsSavingController.provider(roomId)); final joinRules = switch (stateContent(.joinRules)) { final JoinRulesContent content => content, @@ -125,6 +139,19 @@ class RoomSettingsSectionsController(final String roomId) .nonNulls .toISet(); + final roomPowerLevels = ref.watch( + RoomPowerLevelsController.provider(roomId), + ); + final powerLevels = roomPowerLevels.content; + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); + // Encrypted rooms send messages as m.room.encrypted, so that's what limits them + final messageType = stateContent(.encryption) is EncryptionContent + ? EventType.encrypted + : EventType.message; + Future setState(EventType type, Content content) async { final eventId = await client.setState( .new(roomId: roomId, type: type.type, stateKey: "", content: content), @@ -167,6 +194,108 @@ class RoomSettingsSectionsController(final String roomId) ), ); + Setting header(String title, String description, IconData icon) => .new( + title: title, + description: description, + icon: icon, + builder: (title, _, _) => Padding( + padding: .symmetric(horizontal: 16), + child: DividerText(title), + ), + ); + + Future setPowerLevels(PowerLevelsContent content) => ref + .read(PowerLevelsSavingController.provider(roomId).notifier) + .run(() => setState(.powerLevels, content).onError(showError)); + + // Event types with their own level that don't have a setting of their own + final listedEvents = [ + messageType, + EventType.sticker, + EventType.reaction, + EventType.pinnedEvents, + ].map((type) => type.type).toISet(); + final otherEvents = powerLevels.events.entries + .where((entry) => !listedEvents.contains(entry.key)) + .toIList() + .sort((a, b) => a.key.compareTo(b.key)); + + // Users with their own level, the highest first + final users = powerLevels.users.entries.toIList().sort( + (a, b) => switch (b.value.compareTo(a.value)) { + 0 => a.key.compareTo(b.key), + final order => order, + }, + ); + MembershipContent? memberOf(String userId) => + switch (room?.state[EventType.membership.type]?[userId]) { + final rowId? => switch (room?.events[rowId]?.content) { + final MembershipContent content => content, + _ => null, + }, + null => null, + }; + + /// A tile opening a [sheet] to add entries, like event types or users, with their own power level. + Setting addSetting({ + required String title, + required String description, + required Widget Function(void Function(PowerLevelsContent content) save) + sheet, + }) => .new( + title: title, + description: description, + icon: Icons.add, + builder: (title, description, icon) => Builder( + builder: (context) => ListTile( + enabled: canSetPowerLevels, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)), + ), + ), + ); + + Setting powerLevelSetting({ + required String title, + required String description, + required IconData icon, + required int value, + required PowerLevelsContent Function(int level) update, + }) => .new( + title: title, + description: description, + icon: icon, + builder: (title, description, icon) => PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: value, + ownLevel: ownLevel, + onChanged: canSetPowerLevels + ? (level) => setPowerLevels(update(level)) + : null, + ), + ); + + /// A setting for the level needed to send [type], which otherwise needs [fallback]. + Setting eventSetting( + EventType type, { + required String title, + required String description, + required IconData icon, + required int fallback, + }) => powerLevelSetting( + title: title, + description: description, + icon: icon, + value: powerLevels.events[type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(type.type, level), + ), + ); + return .new({ "Room": .new([ .new( @@ -473,6 +602,208 @@ class RoomSettingsSectionsController(final String roomId) ), ]), ), + .new( + title: "Permissions", + icon: Icons.admin_panel_settings, + settings: .new([ + powerLevelSetting( + title: "Default Power Level", + description: "The power level of users who aren't given one.", + icon: Icons.person, + value: powerLevels.usersDefault, + update: (level) => powerLevels.copyWith(usersDefault: level), + ), + header("Users", "Power levels of specific users.", Icons.group), + ...users.map((entry) { + final member = memberOf(entry.key); + // Others can only be changed if they're below you, but your own level can be lowered + final canChange = + canSetPowerLevels && + (entry.key == userId || entry.value < ownLevel); + + return Setting( + title: member?.displayName ?? entry.key, + description: entry.key, + icon: Icons.person, + builder: (title, description, _) => PowerLevelTile( + leading: AvatarOrHash( + member?.avatarUrl, + title, + dimension: 40, + ), + title: title, + description: description, + value: entry.value, + ownLevel: ownLevel, + onChanged: canChange + ? (level) => setPowerLevels( + powerLevels.copyWith( + users: powerLevels.users.add(entry.key, level), + ), + ) + : null, + onRemoved: canChange + ? () => setPowerLevels( + powerLevels.copyWith( + users: powerLevels.users.remove(entry.key), + ), + ) + : null, + removeConfirmation: entry.key == userId + ? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself." + : "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.", + ), + ); + }), + addSetting( + title: "Add Users", + description: "Give users their own power level.", + sheet: (save) => AddUsersPowerLevelSheet( + roomId: roomId, + existing: powerLevels.users.keys.toISet().union( + roomPowerLevels.creators, + ), + ownLevel: ownLevel, + onSave: (users, level) => save( + powerLevels.copyWith( + users: powerLevels.users.addEntries( + users.map((user) => MapEntry(user, level)), + ), + ), + ), + ), + ), + header( + "Regular Events", + "Power levels needed to send messages and other timeline events.", + Icons.chat, + ), + eventSetting( + messageType, + title: "Send Messages", + description: "The power level needed to send messages.", + icon: Icons.message, + fallback: powerLevels.eventsDefault, + ), + eventSetting( + .sticker, + title: "Send Stickers", + description: "The power level needed to send stickers.", + icon: Icons.sticky_note_2, + fallback: powerLevels.eventsDefault, + ), + eventSetting( + .reaction, + title: "Send Reactions", + description: "The power level needed to react to messages.", + icon: Icons.add_reaction, + fallback: powerLevels.eventsDefault, + ), + powerLevelSetting( + title: "Default for Events", + description: + "The power level needed to send events without their own.", + icon: Icons.more_horiz, + value: powerLevels.eventsDefault, + update: (level) => powerLevels.copyWith(eventsDefault: level), + ), + header( + "State Events", + "Power levels needed to manage members and change the room's state.", + Icons.settings, + ), + eventSetting( + .pinnedEvents, + title: "Pin Messages", + description: "The power level needed to pin messages.", + icon: Icons.push_pin, + fallback: powerLevels.stateDefault, + ), + powerLevelSetting( + title: "Invite Users", + description: "The power level needed to invite users.", + icon: Icons.person_add, + value: powerLevels.invite, + update: (level) => powerLevels.copyWith(invite: level), + ), + powerLevelSetting( + title: "Kick Users", + description: "The power level needed to kick users.", + icon: Icons.sports_martial_arts, + value: powerLevels.kick, + update: (level) => powerLevels.copyWith(kick: level), + ), + powerLevelSetting( + title: "Ban Users", + description: "The power level needed to ban and unban users.", + icon: Icons.gavel, + value: powerLevels.ban, + update: (level) => powerLevels.copyWith(ban: level), + ), + powerLevelSetting( + title: "Delete Others' Messages", + description: "The power level needed to remove others' messages.", + icon: Icons.delete, + value: powerLevels.redact, + update: (level) => powerLevels.copyWith(redact: level), + ), + powerLevelSetting( + title: "Default for State Events", + description: "The power level needed to send state events without their own.", + icon: Icons.tune, + value: powerLevels.stateDefault, + update: (level) => powerLevels.copyWith(stateDefault: level), + ), + header( + "Other Events", + "Power levels for specific event types.", + Icons.code, + ), + ...otherEvents.map( + (entry) => Setting( + title: entry.key, + description: "The power level needed to send this event.", + icon: Icons.code, + builder: (title, _, icon) => PowerLevelTile( + leading: Icon(icon), + title: title, + description: null, + value: entry.value, + ownLevel: ownLevel, + onChanged: canSetPowerLevels + ? (level) => setPowerLevels( + powerLevels.copyWith( + events: powerLevels.events.add(entry.key, level), + ), + ) + : null, + onRemoved: canSetPowerLevels + ? () => setPowerLevels( + powerLevels.copyWith( + events: powerLevels.events.remove(entry.key), + ), + ) + : null, + ), + ), + ), + addSetting( + title: "Add Event", + description: "Give an event type its own power level.", + sheet: (save) => PowerLevelEntrySheet( + title: "Add Event", + keyLabel: "Event type", + keyHint: "m.room.name", + ownLevel: ownLevel, + onSave: (type, level) => save( + powerLevels.copyWith( + events: powerLevels.events.add(type, level), + ), + ), + ), + ), + ]), + ), ]), }); } diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index 0df21c9..afa0e23 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { factory PowerLevelsContent({ @Default(IMap.empty()) IMap events, @Default(IMap.empty()) IMap users, - Notifications? notifications, + @JsonKey(includeIfNull: false) Notifications? notifications, @Default(50) int ban, @Default(0) int eventsDefault, @Default(0) int invite, diff --git a/lib/models/room_power_levels.dart b/lib/models/room_power_levels.dart new file mode 100644 index 0000000..028d161 --- /dev/null +++ b/lib/models/room_power_levels.dart @@ -0,0 +1,11 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/content/power_levels.dart"; + +class const RoomPowerLevels({ + required final PowerLevelsContent content, + final ISet creators = const .empty(), +}) { + double levelOf(String userId) => creators.contains(userId) + ? double.infinity + : (content.users[userId] ?? content.usersDefault).toDouble(); +} diff --git a/lib/widgets/settings/add_users_power_level_sheet.dart b/lib/widgets/settings/add_users_power_level_sheet.dart new file mode 100644 index 0000000..2603fd5 --- /dev/null +++ b/lib/widgets/settings/add_users_power_level_sheet.dart @@ -0,0 +1,112 @@ +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/power_level_field.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses users of [roomId] to give a power level of their own. +class const AddUsersPowerLevelSheet({ + required final String roomId, + + /// Users who already have a level, or can't be given one, so aren't suggested. + required final ISet existing, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + required final void Function(ISet users, int level) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + 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(); + onSave(chosen.value, level!); + } + + return SheetLayout( + title: Text("Add Users"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + SearchChooser( + controller: searchController, + 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, + 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( + controller: levelController, + ownLevel: ownLevel, + onSubmitted: valid ? save : null, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/allowed_rooms_sheet.dart b/lib/widgets/settings/allowed_rooms_sheet.dart index c59c527..6ba42fb 100644 --- a/lib/widgets/settings/allowed_rooms_sheet.dart +++ b/lib/widgets/settings/allowed_rooms_sheet.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.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 the rooms whose members can join [roomId] without an invite. @@ -25,7 +26,6 @@ class const AllowedRoomsSheet({ final error = useState(null); final resolving = useState(false); final controller = useTextEditingController(); - final query = useValueListenable(controller).text.trim().toLowerCase(); final rooms = ref.watch( RoomsController.provider.select( (rooms) => IMap.fromEntries( @@ -43,16 +43,6 @@ class const AllowedRoomsSheet({ ); String nameOf(String id) => rooms[id]?.name ?? id; - Widget avatarOf(String id) => AvatarOrHash( - rooms[id]?.avatar, - nameOf(id), - fallback: Icon(Icons.numbers), - ); - String? subtitleOf(String id) => parentSpaces.contains(id) - ? "Parent space" - : rooms[id]?.name == null - ? null - : id; final suggestions = parentSpaces .addAll( @@ -62,15 +52,7 @@ class const AllowedRoomsSheet({ .map((entry) => entry.key), ) .where((id) => id != roomId) - .toISet(); - final shown = suggestions - .where( - (id) => - !chosen.value.contains(id) && - (nameOf(id).toLowerCase().contains(query) || - id.toLowerCase().contains(query)), - ) - .toISet(); + .toIList(); void add(String id) { if (id == roomId) { @@ -116,93 +98,39 @@ class const AllowedRoomsSheet({ ], children: [ Text("Members of these rooms can join without an invite."), - if (chosen.value.isEmpty) - Text("None, so only invited users can join") - else - Column( - children: chosen.value - .map( - (id) => ListTile( - leading: avatarOf(id), - title: Text(nameOf(id), overflow: .ellipsis), - subtitle: switch (subtitleOf(id)) { - final subtitle? => Text(subtitle, overflow: .ellipsis), - null => null, - }, - trailing: IconButton( - tooltip: "Remove room", - icon: Icon(Icons.delete), - onPressed: () => chosen.value = chosen.value.remove(id), - ), - ), - ) - .toList(), - ), - TextField( + SearchChooser( controller: controller, - enabled: !resolving.value, - onChanged: (_) => error.value = null, - onSubmitted: (text) { - final trimmed = text.trim(); - if (trimmed.isEmpty) return; - - if (trimmed.startsWith(RegExp("[!#]"))) { - addRoom(trimmed).onError(showError); - } else if (shown.firstOrNull case final id?) { - add(id); + chosen: chosen.value, + options: suggestions, + nameOf: nameOf, + avatarOf: (id) => AvatarOrHash( + rooms[id]?.avatar, + nameOf(id), + fallback: Icon(Icons.numbers), + ), + subtitleOf: (id) => parentSpaces.contains(id) + ? "Parent space" + : 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"; } }, - decoration: .new( - labelText: "Add room", - hintText: "Search, or enter a room ID or alias", - errorText: error.value, - suffixIcon: resolving.value - ? Padding( - padding: .all(12), - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(), - ), - ) - : null, - ), - ), - AnimatedSize( - duration: Durations.short2, - alignment: .topCenter, - child: shown.isEmpty - ? SizedBox(width: .infinity) - : Material( - elevation: 4, - borderRadius: .circular(12), - clipBehavior: .antiAlias, - child: ConstrainedBox( - constraints: const .new(maxHeight: 240), - child: ListView.builder( - padding: .zero, - shrinkWrap: true, - itemExtent: 72, - itemCount: shown.length, - itemBuilder: (context, index) { - final id = shown[index]; - return ListTile( - leading: avatarOf(id), - title: Text(nameOf(id), overflow: .ellipsis), - subtitle: switch (subtitleOf(id)) { - final subtitle? => Text( - subtitle, - overflow: .ellipsis, - ), - null => null, - }, - onTap: () => add(id), - ); - }, - ), - ), - ), + onChanged: (_) => error.value = null, + label: "Add room", + hint: "Search, or enter a room ID or alias", + removeTooltip: "Remove room", + emptyText: "None, so only invited users can join", + error: error.value, + loading: resolving.value, ), ], ); diff --git a/lib/widgets/settings/power_level_entry_sheet.dart b/lib/widgets/settings/power_level_entry_sheet.dart new file mode 100644 index 0000000..9d5e457 --- /dev/null +++ b/lib/widgets/settings/power_level_entry_sheet.dart @@ -0,0 +1,55 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Gives something, like an event type, its own power level. +class const PowerLevelEntrySheet({ + required final String title, + + /// The label of the field for what's given the power level. + required final String keyLabel, + required final String keyHint, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + required final void Function(String key, int level) onSave, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final keyController = useTextEditingController(); + final levelController = useTextEditingController(text: "0"); + final key = useValueListenable(keyController).text.trim(); + final level = PowerLevelField.levelFrom( + useValueListenable(levelController).text, + ownLevel, + ); + final valid = key.isNotEmpty && level != null; + + void save() { + Navigator.of(context).pop(); + onSave(key, level!); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: keyController, + autofocus: true, + decoration: .new(labelText: keyLabel, hintText: keyHint), + ), + PowerLevelField( + controller: levelController, + ownLevel: ownLevel, + onSubmitted: valid ? save : null, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/power_level_field.dart b/lib/widgets/settings/power_level_field.dart new file mode 100644 index 0000000..fe539bf --- /dev/null +++ b/lib/widgets/settings/power_level_field.dart @@ -0,0 +1,48 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// A field for a power level, which can't be above the user's own. +class const PowerLevelField({ + required final TextEditingController controller, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + final VoidCallback? onSubmitted, + final bool autofocus = false, + super.key, +}) extends HookWidget { + /// The furthest from zero Matrix allows integers to be. + static const maxMagnitude = (2 ^ 53) - 1; + + /// The level in [text], if it's one someone with [ownLevel] can set. + static int? levelFrom(String text, double ownLevel) => switch (int.tryParse( + text, + )) { + final level? when level.abs() <= maxMagnitude && level <= ownLevel => level, + _ => null, + }; + + @override + Widget build(BuildContext context) { + final text = useValueListenable(controller).text; + + return TextField( + controller: controller, + autofocus: autofocus, + keyboardType: .numberWithOptions(signed: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))], + onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(), + decoration: .new( + labelText: "Power level", + errorText: switch (int.tryParse(text)) { + _ when text.isEmpty || text == "-" => null, + final level? when level.abs() <= maxMagnitude => + level > ownLevel ? "Can't be above your own power level" : null, + // Including ones too far from zero to even parse + _ => "Must be from -$maxMagnitude to $maxMagnitude", + }, + ), + ); + } +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart new file mode 100644 index 0000000..5b7d4e6 --- /dev/null +++ b/lib/widgets/settings/power_level_tile.dart @@ -0,0 +1,120 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Sets the power level needed for something. +class const PowerLevelTile({ + required final Widget leading, + required final String title, + required final String? description, + required final int value, + + /// The user's own power level, which they can't set or change levels above. + required final double ownLevel, + + /// Disables the tile if null. + required final void Function(int value)? onChanged, + + /// Shows a button to remove the level if given, disabled along with the tile. + final VoidCallback? onRemoved, + + /// If given, asked before removing the level. + final String? removeConfirmation, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final enabled = onChanged != null && value <= ownLevel; + + Future remove() async { + if (removeConfirmation case final confirmation?) { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Remove Power Level"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + ], + children: [Text(confirmation)], + ), + ); + if (confirmed != true) return; + } + onRemoved!(); + } + + return ListTile( + enabled: enabled, + leading: leading, + title: Text(title), + subtitle: description == null ? null : Text(description!), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Chip( + label: Text( + "$value", + style: enabled ? null : .new(color: Colors.grey), + ), + ), + if (onRemoved != null) + IconButton( + tooltip: "Remove", + icon: Icon(Icons.delete), + onPressed: enabled ? remove : null, + ), + ], + ), + onTap: () => context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final controller = useTextEditingController(text: "$value"); + final level = PowerLevelField.levelFrom( + useValueListenable(controller).text, + ownLevel, + ); + + void save() { + Navigator.of(context).pop(); + if (level != value) onChanged!(level!); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: level == null ? null : save, + child: Text("Save"), + ), + ], + children: [ + PowerLevelField( + controller: controller, + ownLevel: ownLevel, + autofocus: true, + onSubmitted: level == null ? null : save, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/settings/search_chooser.dart b/lib/widgets/settings/search_chooser.dart new file mode 100644 index 0000000..feadcdd --- /dev/null +++ b/lib/widgets/settings/search_chooser.dart @@ -0,0 +1,142 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// The [chosen] items, above a field searching [options] to choose more. +class const SearchChooser({ + required final TextEditingController controller, + required final ISet chosen, + + required final IList options, + + required final String Function(String id) nameOf, + required final Widget Function(String id) avatarOf, + required final String? Function(String id) subtitleOf, + required final void Function(String id) onAdded, + required final void Function(String id) onRemoved, + + /// The submitted text, and the top suggestion matching it if there is one. + required final void Function(String text, String? topMatch) onSubmitted, + required final String label, + required final String hint, + required final String removeTooltip, + + final String? emptyText, + final String? error, + final bool loading = false, + final bool autofocus = false, + final void Function(String text)? onChanged, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final query = useValueListenable(controller).text.trim().toLowerCase(); + final shown = options + .where( + (id) => + !chosen.contains(id) && + (nameOf(id).toLowerCase().contains(query) || + id.toLowerCase().contains(query)), + ) + .toIList(); + + ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) => + ListTile( + leading: avatarOf(id), + title: Text(nameOf(id), overflow: .ellipsis), + subtitle: switch (subtitleOf(id)) { + final subtitle? => Text(subtitle, overflow: .ellipsis), + null => null, + }, + trailing: trailing, + onTap: onTap, + ); + + AnimatedSize animated(Widget? child) => AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: child ?? const SizedBox(width: .infinity), + ); + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + animated(switch ((chosen.isEmpty, emptyText)) { + (true, null) => null, + (true, final emptyText?) => Padding( + padding: .only(bottom: 12), + child: Text(emptyText), + ), + (false, _) => Padding( + padding: .only(bottom: 12), + child: Column( + children: chosen + .map( + (id) => tileOf( + id, + trailing: IconButton( + tooltip: removeTooltip, + icon: Icon(Icons.delete), + onPressed: () => onRemoved(id), + ), + ), + ) + .toList(), + ), + ), + }), + TextField( + controller: controller, + enabled: !loading, + autofocus: autofocus, + onChanged: onChanged, + onSubmitted: (text) { + final trimmed = text.trim(); + if (trimmed.isNotEmpty) onSubmitted(trimmed, shown.firstOrNull); + }, + decoration: .new( + labelText: label, + hintText: hint, + errorText: error, + suffixIcon: loading + ? Padding( + padding: .all(12), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ), + ) + : null, + ), + ), + animated( + shown.isEmpty + ? null + : Padding( + padding: .only(top: 12, bottom: 4), + child: Material( + elevation: 4, + borderRadius: .circular(12), + clipBehavior: .antiAlias, + child: ConstrainedBox( + constraints: const .new(maxHeight: 240), + child: ListView.builder( + padding: .zero, + shrinkWrap: true, + itemExtent: 72, + itemCount: shown.length, + itemBuilder: (context, index) => tileOf( + shown[index], + onTap: () => onAdded(shown[index]), + ), + ), + ), + ), + ), + ), + ], + ); + } +}