add power level groups

This commit is contained in:
Henry Hiles 2026-10-23 12:24:22 -04:00
commit 6336764da0
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 216 additions and 11 deletions

View file

@ -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",

View file

@ -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),

View file

@ -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<PowerLevelEvent> 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<PowerLevelPermission> get permissions => .new(switch (this) {
.moderation => [.kick, .ban, .redact, .notifyRoom],
.roomDetails || .administration => [],
});
IList<PowerLevelEvent> 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)),
),
);
}

View file

@ -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<void> 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,