add power level room settings

This commit is contained in:
Henry Hiles 2026-10-22 15:29:02 -04:00
commit b0dd452926
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
14 changed files with 944 additions and 179 deletions

View file

@ -1,44 +1,27 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/room_creators.dart"; import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/members_by_status.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"; import "package:nexus/models/event.dart";
class MembersGroupedController(final MembersByStatusConfig config) class MembersGroupedController(final MembersByStatusConfig config)
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> { extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
@override @override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async { Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch( final powerLevels = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]), 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( final members = await ref.watch(
MembersByStatusController.provider(config).future, MembersByStatusController.provider(config).future,
); );
return members return members
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) { .fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = roomCreators?.contains(event.stateKey!) == true final groupKey = powerLevels.creators.contains(event.stateKey!)
? null ? null
: content.users[event.stateKey!] ?? content.usersDefault; : powerLevels.levelOf(event.stateKey!).toInt();
return result.update( return result.update(
groupKey, groupKey,

View file

@ -1,10 +1,8 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/room_creators.dart"; import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/power_level.dart"; import "package:nexus/models/configs/power_level.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
class PowerLevelController(final PowerLevelConfig config) class PowerLevelController(final PowerLevelConfig config)
extends Notifier<bool> { extends Notifier<bool> {
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
); );
} }
final room = ref.watch( final powerLevels = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]), RoomPowerLevelsController.provider(config.roomId),
); );
final content = powerLevels.content;
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 user = ref.watch( final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId), 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 final userLevel = powerLevels.levelOf(user);
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
final userLevel = powerLevelOf(user);
return switch (config) { return switch (config) {
EventPowerLevelConfig(:final eventType) => EventPowerLevelConfig(:final eventType) =>
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
.invite => userLevel >= content.invite, .invite => userLevel >= content.invite,
.kick => .kick =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser), userLevel >= content.kick &&
userLevel > powerLevels.levelOf(targetUser),
.ban => .ban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser), userLevel >= content.ban &&
userLevel > powerLevels.levelOf(targetUser),
.unban => userLevel >= content.ban, .unban => userLevel >= content.ban,
}, },

View file

@ -0,0 +1,20 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
@override
bool build() => false;
Future<void> run(Future<void> Function() action) async {
state = true;
try {
await action();
} finally {
if (ref.mounted) state = false;
}
}
static final provider = NotifierProvider.autoDispose
.family<PowerLevelsSavingController, bool, String>(
PowerLevelsSavingController.new,
);
}

View file

@ -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<IList<String>> {
@override
IList<String> 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<String> creators => creators.add(createEvent.sender),
_ => .new([createEvent.sender]),
};
}
static final provider =
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
RoomCreatorsController.new,
);
}

View file

@ -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<RoomPowerLevels> {
@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, RoomPowerLevels, String>(
RoomPowerLevelsController.new,
);
}

View file

@ -3,8 +3,11 @@ 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/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.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/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/canonical_alias.dart";
import "package:nexus/models/content/content.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/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.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/message.dart";
import "package:nexus/models/content/name.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/content/topic.dart";
import "package:nexus/models/join_rule.dart"; import "package:nexus/models/join_rule.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.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/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_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/dialog_list_tile.dart";
import "package:nexus/widgets/settings/local_aliases_sheet.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"; import "package:nexus/widgets/settings/text_setting_tile.dart";
class RoomSettingsSectionsController(final String roomId) class RoomSettingsSectionsController(final String roomId)
@ -69,6 +80,9 @@ class RoomSettingsSectionsController(final String roomId)
final canSetJoinRules = canSet(.joinRules); final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility); final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias); final canSetAliases = canSet(.canonicalAlias);
final canSetPowerLevels =
canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId));
final joinRules = switch (stateContent(.joinRules)) { final joinRules = switch (stateContent(.joinRules)) {
final JoinRulesContent content => content, final JoinRulesContent content => content,
@ -125,6 +139,19 @@ class RoomSettingsSectionsController(final String roomId)
.nonNulls .nonNulls
.toISet(); .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<void> setState(EventType type, Content content) async { Future<void> setState(EventType type, Content content) async {
final eventId = await client.setState( final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content), .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<void> 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({ return .new({
"Room": .new([ "Room": .new([
.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),
),
),
),
),
]),
),
]), ]),
}); });
} }

View file

@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
factory PowerLevelsContent({ factory PowerLevelsContent({
@Default(IMap.empty()) IMap<String, int> events, @Default(IMap.empty()) IMap<String, int> events,
@Default(IMap.empty()) IMap<String, int> users, @Default(IMap.empty()) IMap<String, int> users,
Notifications? notifications, @JsonKey(includeIfNull: false) Notifications? notifications,
@Default(50) int ban, @Default(50) int ban,
@Default(0) int eventsDefault, @Default(0) int eventsDefault,
@Default(0) int invite, @Default(0) int invite,

View file

@ -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<String> creators = const .empty(),
}) {
double levelOf(String userId) => creators.contains(userId)
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
}

View file

@ -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<String> existing,
/// The user's own power level, which they can't set levels above.
required final double ownLevel,
required final void Function(ISet<String> users, int level) onSave,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(const ISet<String>.empty());
final error = useState<String?>(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,
),
],
);
}
}

View file

@ -7,6 +7,7 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/settings/search_chooser.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
/// Chooses the rooms whose members can join [roomId] without an invite. /// Chooses the rooms whose members can join [roomId] without an invite.
@ -25,7 +26,6 @@ class const AllowedRoomsSheet({
final error = useState<String?>(null); final error = useState<String?>(null);
final resolving = useState(false); final resolving = useState(false);
final controller = useTextEditingController(); final controller = useTextEditingController();
final query = useValueListenable(controller).text.trim().toLowerCase();
final rooms = ref.watch( final rooms = ref.watch(
RoomsController.provider.select( RoomsController.provider.select(
(rooms) => IMap.fromEntries( (rooms) => IMap.fromEntries(
@ -43,16 +43,6 @@ class const AllowedRoomsSheet({
); );
String nameOf(String id) => rooms[id]?.name ?? id; 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 final suggestions = parentSpaces
.addAll( .addAll(
@ -62,15 +52,7 @@ class const AllowedRoomsSheet({
.map((entry) => entry.key), .map((entry) => entry.key),
) )
.where((id) => id != roomId) .where((id) => id != roomId)
.toISet(); .toIList();
final shown = suggestions
.where(
(id) =>
!chosen.value.contains(id) &&
(nameOf(id).toLowerCase().contains(query) ||
id.toLowerCase().contains(query)),
)
.toISet();
void add(String id) { void add(String id) {
if (id == roomId) { if (id == roomId) {
@ -116,93 +98,39 @@ class const AllowedRoomsSheet({
], ],
children: [ children: [
Text("Members of these rooms can join without an invite."), Text("Members of these rooms can join without an invite."),
if (chosen.value.isEmpty) SearchChooser(
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(
controller: controller, controller: controller,
enabled: !resolving.value, chosen: chosen.value,
onChanged: (_) => error.value = null, options: suggestions,
onSubmitted: (text) { nameOf: nameOf,
final trimmed = text.trim(); avatarOf: (id) => AvatarOrHash(
if (trimmed.isEmpty) return; rooms[id]?.avatar,
nameOf(id),
if (trimmed.startsWith(RegExp("[!#]"))) { fallback: Icon(Icons.numbers),
addRoom(trimmed).onError(showError); ),
} else if (shown.firstOrNull case final id?) { subtitleOf: (id) => parentSpaces.contains(id)
add(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 { } else {
error.value = "Enter a room ID or alias"; error.value = "Enter a room ID or alias";
} }
}, },
decoration: .new( onChanged: (_) => error.value = null,
labelText: "Add room", label: "Add room",
hintText: "Search, or enter a room ID or alias", hint: "Search, or enter a room ID or alias",
errorText: error.value, removeTooltip: "Remove room",
suffixIcon: resolving.value emptyText: "None, so only invited users can join",
? Padding( error: error.value,
padding: .all(12), loading: resolving.value,
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),
);
},
),
),
),
), ),
], ],
); );

View file

@ -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,
),
],
);
}
}

View file

@ -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",
},
),
);
}
}

View file

@ -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<void> remove() async {
if (removeConfirmation case final confirmation?) {
final confirmed = await context.showAdaptiveSheet<bool>(
(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,
),
],
);
},
),
),
);
}
}

View file

@ -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<String> chosen,
required final IList<String> 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]),
),
),
),
),
),
),
],
);
}
}