add room access settings

This commit is contained in:
Henry Hiles 2026-10-22 14:22:54 -04:00
commit b390dbbbe1
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 974 additions and 128 deletions

View file

@ -0,0 +1,78 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Chooses which of a room's [aliases] it publishes, and which one of those is canonical.
class const AliasesSheet({
required final ISet<String> aliases,
required final String? canonical,
required final ISet<String> alternatives,
/// The canonical alias, and the other published ones.
required final void Function(String? canonical, ISet<String> alternatives)
onSave,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final chosenCanonical = useState(canonical);
final published = useState(alternatives.addAll([?canonical]));
void setPublished(String alias, bool isPublished) {
published.value = isPublished
? published.value.add(alias)
: published.value.remove(alias);
if (!isPublished && chosenCanonical.value == alias) {
chosenCanonical.value = null;
}
}
return SheetLayout(
title: Text("Aliases"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onSave(
chosenCanonical.value,
published.value.removeAll([?chosenCanonical.value]),
);
},
child: Text("Save"),
),
],
children: [
Text(
"Star an item to set it as the room's canonical alias, the alias used to advertise and access the room",
),
Text(
"Other checked items will be set as alternate aliases, which can still be used to join/access the room but are not as prominently advertised.",
),
if (aliases.isEmpty)
Text("No aliases, add a local alias first")
else
...aliases.map(
(alias) => CheckboxListTile(
value: published.value.contains(alias),
onChanged: (checked) => setPublished(alias, checked ?? false),
title: Text(alias, overflow: .ellipsis),
secondary: IconButton(
tooltip: chosenCanonical.value == alias
? "Canonical alias"
: "Make canonical alias",
isSelected: chosenCanonical.value == alias,
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
onPressed: () {
chosenCanonical.value = alias;
setPublished(alias, true);
},
),
),
),
],
);
}
}

View file

@ -0,0 +1,210 @@
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/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/sheet_layout.dart";
/// Chooses the rooms whose members can join [roomId] without an invite.
class const AllowedRoomsSheet({
required final String roomId,
required final ISet<String> allowed,
/// Suggested first, as they're the usual choice.
required final ISet<String> parentSpaces,
required final void Function(ISet<String> allowed) onSave,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(allowed);
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(
rooms.values
.map((room) => room.metadata)
.nonNulls
.map(
(metadata) => MapEntry(metadata.id, (
name: metadata.name,
avatar: metadata.avatar,
)),
),
),
),
);
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(
rooms.entries
.where((entry) => !parentSpaces.contains(entry.key))
.sortedBy((entry) => entry.value.name?.toLowerCase() ?? "")
.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();
void add(String id) {
if (id == roomId) {
error.value = "A room can't allow itself";
return;
}
if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id);
controller.clear();
}
Future<void> addRoom(String text) async {
if (text.startsWith("!")) return add(text);
resolving.value = true;
try {
final id = await ref
.read(ClientController.provider.notifier)
.resolveAlias(text);
if (!context.mounted) return;
if (id == null) {
error.value = "No room has this alias";
} else {
add(id);
}
} finally {
if (context.mounted) resolving.value = false;
}
}
return SheetLayout(
title: Text("Allowed Rooms"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onSave(chosen.value);
},
child: Text("Save"),
),
],
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(
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);
} 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),
);
},
),
),
),
),
],
);
}
}

View file

@ -1,6 +1,9 @@
import "dart:async";
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:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({
@ -8,32 +11,44 @@ final class const DialogListTile<T>({
required final Icon icon,
required final String title,
required final List<T> options,
required final void Function(T value)? onChanged,
/// While a returned future runs, the tile is disabled.
required final FutureOr<void> Function(T value)? onChanged,
required final String Function(T option) getName,
final Widget? subtitle,
super.key,
}) extends ConsumerWidget {
}) extends HookWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ListTile(
enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged,
value: value,
options: options,
Widget build(BuildContext context) {
final saving = Saving.use();
Future<void> change(T value) => saving.run(() => onChanged!(value));
return ListTile(
enabled: onChanged != null && !saving.active,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged == null ? null : change,
value: value,
options: options,
),
),
),
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: Chip(
label: Text(
value == null ? "None" : getName(value as T),
overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null,
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: ConstrainedBox(
constraints: const .new(maxWidth: 160),
child: Chip(
label: Text(
value is T ? getName(value as T) : "None",
overflow: TextOverflow.ellipsis,
style: onChanged == null || saving.active
? .new(color: Colors.grey)
: null,
),
),
),
),
);
);
}
}

View file

@ -0,0 +1,107 @@
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_state.dart";
import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/gomuks_exception.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const LocalAliasesSheet(
final String roomId, {
/// Called after an alias is deleted, e.g. to stop publishing it.
final Future<void> Function(String alias)? onRemoved,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = LocalAliasesController.provider(roomId);
final saving = Saving.use();
final controller = useTextEditingController();
final localpart = useValueListenable(controller).text.trim();
final server = ref.watch(
ClientStateController.provider.select(
(state) => state?.userId?.split(":").skip(1).join(":"),
),
);
final error = useState<String?>(null);
void add() {
if (localpart.isEmpty || server == null) return;
saving
.run(() async {
try {
await ref.read(provider.notifier).add("#$localpart:$server");
controller.clear();
} on GomuksException catch (exception) {
// Taken, by this room or another, which is shown on the field
if (exception.statusCode != 409) rethrow;
if (context.mounted) error.value = "This alias already exists";
}
})
.onError(showError);
}
return SheetLayout(
title: Text("Local Aliases"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
],
children: [
Text(
"Addresses for the room on your server. Publish them to show them to others.",
),
ref
.watch(provider)
.betterWhen(
data: (aliases) => aliases.isEmpty
? Text("No local aliases")
: Column(
children: aliases
.map(
(alias) => ListTile(
title: Text(alias, overflow: .ellipsis),
trailing: IconButton(
tooltip: "Delete alias",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => saving
.run(() async {
await ref
.read(provider.notifier)
.remove(alias);
await onRemoved?.call(alias);
})
.onError(showError),
),
),
)
.toList(),
),
),
TextField(
controller: controller,
enabled: !saving.active,
onChanged: (_) => error.value = null,
onSubmitted: (_) => add(),
decoration: .new(
labelText: "New alias",
errorText: error.value,
prefixText: "#",
suffixText: server == null ? null : ":$server",
suffixIcon: IconButton(
tooltip: "Add alias",
icon: Icon(Icons.add),
onPressed: localpart.isEmpty || saving.active ? null : add,
),
),
),
],
);
}
}

View file

@ -12,7 +12,8 @@ final class const RadioSheet<T>({
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final mutValue = useState<T?>(null);
// Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet
final chosen = useState<(T,)?>(null);
return SheetLayout(
title: Text(title),
actions: [
@ -20,7 +21,7 @@ final class const RadioSheet<T>({
if (onChanged != null)
TextButton(
onPressed: () {
if (mutValue.value != null) onChanged!(mutValue.value as T);
if (chosen.value case (final picked,)) onChanged!(picked);
Navigator.of(context).pop();
},
child: Text("OK"),
@ -28,8 +29,11 @@ final class const RadioSheet<T>({
],
children: [
RadioGroup<T>(
groupValue: mutValue.value ?? value,
onChanged: (value) => mutValue.value = value ?? mutValue.value,
groupValue: switch (chosen.value) {
(final picked,) => picked,
null => value,
},
onChanged: (value) => chosen.value = (value as T,),
child: Column(
mainAxisSize: MainAxisSize.min,

View file

@ -120,6 +120,7 @@ class const SettingsLayout({
body: ref
.watch(sectionsProvider)
.betterWhen(
skipLoadingOnReload: true,
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [

View file

@ -1,6 +1,9 @@
import "dart:async";
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/helpers/hooks/saving.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const TextSettingTile({
@ -9,54 +12,59 @@ class const TextSettingTile({
required final String description,
required final String? value,
/// Disables the tile if null
required final void Function(String value)? onChanged,
/// Disables the tile if null, and while a returned future runs.
required final FutureOr<void> Function(String value)? onChanged,
final bool multiline = false,
super.key,
}) extends StatelessWidget {
}) extends HookWidget {
@override
Widget build(BuildContext context) => ListTile(
enabled: onChanged != null,
leading: icon,
title: Text(title),
subtitle: Text(
value?.isNotEmpty == true ? value! : description,
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final controller = useTextEditingController(text: value);
Widget build(BuildContext context) {
final saving = Saving.use();
Future<void> change(String value) => saving.run(() => onChanged!(value));
void save() {
Navigator.of(context).pop();
onChanged!(controller.text.trim());
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(onPressed: save, child: Text("Save")),
],
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: .sentences,
minLines: 1,
maxLines: multiline ? 6 : 1,
onSubmitted: multiline ? null : (_) => save(),
decoration: .new(labelText: title),
),
],
);
},
return ListTile(
enabled: onChanged != null && !saving.active,
leading: icon,
title: Text(title),
subtitle: Text(
value?.isNotEmpty == true ? value! : description,
maxLines: 2,
overflow: .ellipsis,
),
),
);
onTap: () => context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final controller = useTextEditingController(text: value);
void save() {
Navigator.of(context).pop();
change(controller.text.trim());
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(onPressed: save, child: Text("Save")),
],
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: .sentences,
minLines: 1,
maxLines: multiline ? 6 : 1,
onSubmitted: multiline ? null : (_) => save(),
decoration: .new(labelText: title),
),
],
);
},
),
),
);
}
}