From 6336764da0323efda6198b481e80ffbfa5e5b63d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 12:24:22 -0400 Subject: [PATCH] add power level groups --- lib/controllers/room_settings_sections.dart | 87 +++++++++++++- lib/models/content/content.dart | 1 + lib/models/power_level_permission.dart | 123 +++++++++++++++++++- lib/widgets/settings/power_level_tile.dart | 16 ++- 4 files changed, 216 insertions(+), 11 deletions(-) diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 8ce08f8..e6ea31e 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -218,7 +218,10 @@ class RoomSettingsSectionsController(final String roomId) .run(() => setState(.powerLevels, content).onError(showError)); // Event types with their own level that don't have a setting of their own - final listedEvents = shownEvents.map((event) => event.type.type).toISet(); + final listedEvents = shownEvents + .addAll(PowerLevelGroup.values.expand((group) => group.events)) + .map((event) => event.type.type) + .toISet(); final otherEvents = powerLevels.events.entries .where((entry) => !listedEvents.contains(entry.key)) .toIList() @@ -304,6 +307,76 @@ class RoomSettingsSectionsController(final String roomId) ), ); + Widget tileOf(Setting setting) => + setting.builder(setting.title, setting.description, setting.icon); + + /// A setting for all of [group]'s levels at once, which expands to set each. + Setting groupSetting(PowerLevelGroup group) => .new( + title: group.title, + description: group.description, + icon: group.icon, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final expanded = useState(false); + + return Column( + mainAxisSize: .min, + children: [ + PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: group.levelIn(powerLevels), + ownLevel: ownLevel, + // Changing it would change levels above the user's own + onChanged: + canSetPowerLevels && + group.highestIn(powerLevels) <= ownLevel + ? (level) => + setPowerLevels(group.withLevel(powerLevels, level)) + : null, + trailing: IconButton( + tooltip: expanded.value ? "Collapse" : "Set each", + icon: AnimatedRotation( + turns: expanded.value ? 0.5 : 0, + duration: Durations.medium2, + child: Icon(Icons.expand_more), + ), + onPressed: () => expanded.value = !expanded.value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: expanded.value + ? Padding( + padding: .only(left: 16), + child: Column( + mainAxisSize: .min, + children: [ + ...group.permissions + .map(permissionSetting) + .map(tileOf), + ...group.events + .map( + (event) => eventSetting( + event, + fallback: powerLevels.stateDefault, + ), + ) + .map(tileOf), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, + ), + ); + return .new({ "Room": .new([ .new( @@ -668,6 +741,12 @@ class RoomSettingsSectionsController(final String roomId) ), ), ), + header( + "Groups", + "Power levels set together, which can each be set on their own.", + Icons.workspaces, + ), + ...PowerLevelGroup.values.map(groupSetting), header( "Regular Events", "Power levels needed to send messages and other timeline events.", @@ -682,14 +761,10 @@ class RoomSettingsSectionsController(final String roomId) permissionSetting(.eventsDefault), header( "State Events", - "Power levels needed to manage members and change the room's state.", + "Power levels needed to change the room's state.", Icons.settings, ), - eventSetting(.pinnedEvents, fallback: powerLevels.stateDefault), permissionSetting(.invite), - permissionSetting(.kick), - permissionSetting(.ban), - permissionSetting(.redact), permissionSetting(.stateDefault), header( "Other Events", diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 37a87b7..e6061d1 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -58,6 +58,7 @@ enum EventType( avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), + tombstone("m.room.tombstone", Content.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), message("m.room.message", MessageContent.fromJson), diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart index 355623b..8c26ee2 100644 --- a/lib/models/power_level_permission.dart +++ b/lib/models/power_level_permission.dart @@ -1,3 +1,5 @@ +import "dart:math"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/models/content/content.dart"; @@ -113,14 +115,133 @@ enum PowerLevelEvent( "Pin Messages", "The power level needed to pin messages.", Icons.push_pin, + ), + name( + .name, + "Change Name", + "The power level needed to change the room's name.", + Icons.title, + ), + topic( + .topic, + "Change Topic", + "The power level needed to change the room's topic.", + Icons.description, + ), + avatar( + .avatar, + "Change Avatar", + "The power level needed to change the room's avatar.", + Icons.image, + ), + canonicalAlias( + .canonicalAlias, + "Change Aliases", + "The power level needed to change the room's canonical alias and published aliases.", + Icons.numbers, + ), + powerLevels( + .powerLevels, + "Change Power Levels", + "The power level needed to change power levels, including these.", + Icons.admin_panel_settings, + ), + joinRules( + .joinRules, + "Change Join Rule", + "The power level needed to change who can join the room.", + Icons.login, + ), + historyVisibility( + .historyVisibility, + "Change History Visibility", + "The power level needed to change who can read the room's history.", + Icons.history, + ), + serverACL( + .serverACL, + "Change Server ACL", + "The power level needed to change which servers can take part in the room.", + Icons.dns, + ), + encryption( + .encryption, + "Enable Encryption", + "The power level needed to enable encryption, which can't be turned off.", + Icons.lock, + ), + tombstone( + .tombstone, + "Upgrade Room", + "The power level needed to replace the room with an upgraded one.", + Icons.upgrade, ); static IList shownIn({required bool encrypted}) => .new([ if (encrypted) .encrypted else ...[.message, .sticker], .reaction, - .pinnedEvents, ]); static PowerLevelEvent? of(String type) => values.where((event) => event.type.type == type).firstOrNull; } + +/// Power levels set together, like everything moderators need, to save +/// setting each one. Each can still be set on its own, by expanding it. +enum PowerLevelGroup(final String title, final IconData icon) { + moderation("Moderation", Icons.shield), + roomDetails("Room Details", Icons.edit), + administration("Administration", Icons.admin_panel_settings); + + IList get permissions => .new(switch (this) { + .moderation => [.kick, .ban, .redact, .notifyRoom], + .roomDetails || .administration => [], + }); + + IList get events => .new(switch (this) { + .moderation => [], + .roomDetails => [.name, .topic, .avatar, .canonicalAlias, .pinnedEvents], + .administration => [ + .powerLevels, + .joinRules, + .historyVisibility, + .serverACL, + .encryption, + .tombstone, + ], + }); + + String get description => [ + ...permissions.map((permission) => permission.title), + ...events.map((event) => event.title), + ].join(", "); + + int? levelIn(PowerLevelsContent content) { + final levels = { + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + }; + return levels.length == 1 ? levels.single : null; + } + + int highestIn(PowerLevelsContent content) => [ + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + ].reduce(max); + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + permissions + .fold( + content, + (content, permission) => permission.withLevel(content, level), + ) + .copyWith( + events: content.events.addEntries( + events.map((event) => MapEntry(event.type.type, level)), + ), + ); +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart index 5b7d4e6..a553c62 100644 --- a/lib/widgets/settings/power_level_tile.dart +++ b/lib/widgets/settings/power_level_tile.dart @@ -9,7 +9,9 @@ class const PowerLevelTile({ required final Widget leading, required final String title, required final String? description, - required final int value, + + /// Null for several levels that differ, shown as custom. + required final int? value, /// The user's own power level, which they can't set or change levels above. required final double ownLevel, @@ -22,11 +24,14 @@ class const PowerLevelTile({ /// If given, asked before removing the level. final String? removeConfirmation, + + /// Shown after the level, like a button to expand what it sets. + final Widget? trailing, super.key, }) extends StatelessWidget { @override Widget build(BuildContext context) { - final enabled = onChanged != null && value <= ownLevel; + final enabled = onChanged != null && (value ?? 0) <= ownLevel; Future remove() async { if (removeConfirmation case final confirmation?) { @@ -65,7 +70,7 @@ class const PowerLevelTile({ children: [ Chip( label: Text( - "$value", + value?.toString() ?? "Custom", style: enabled ? null : .new(color: Colors.grey), ), ), @@ -75,12 +80,15 @@ class const PowerLevelTile({ icon: Icon(Icons.delete), onPressed: enabled ? remove : null, ), + ?trailing, ], ), onTap: () => context.showAdaptiveSheet( (context) => HookBuilder( builder: (context) { - final controller = useTextEditingController(text: "$value"); + final controller = useTextEditingController( + text: value?.toString(), + ); final level = PowerLevelField.levelFrom( useValueListenable(controller).text, ownLevel,