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
6 changed files with 944 additions and 179 deletions

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/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<String?>(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,
),
],
);

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