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
17 changed files with 974 additions and 128 deletions

View file

@ -377,6 +377,28 @@ class ClientController extends AsyncNotifier<int> {
?.cast<String>(),
);
Future<IList<String>> getLocalAliases(String roomId) async => IList(
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
as List?)
?.cast<String>(),
);
Future<void> createAlias(String roomId, String alias) =>
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
Future<void> deleteAlias(String alias) =>
_sendCommand("delete_alias", {"alias": alias});
/// The room [alias] points to, or null if it doesn't exist.
Future<String?> resolveAlias(String alias) async {
try {
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
} on GomuksException catch (error) {
if (error.statusCode == 404) return null;
rethrow;
}
}
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));

View file

@ -0,0 +1,28 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
/// A room's aliases on the user's own server.
class LocalAliasesController(final String roomId)
extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() =>
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
Future<void> add(String alias) async {
await ref
.read(ClientController.provider.notifier)
.createAlias(roomId, alias);
ref.invalidateSelf();
}
Future<void> remove(String alias) async {
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
ref.invalidateSelf();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<LocalAliasesController, IList<String>, String>(
LocalAliasesController.new,
);
}

View file

@ -1,19 +1,32 @@
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/local_aliases.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/join_rule.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/settings/aliases_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/local_aliases_sheet.dart";
import "package:nexus/widgets/settings/text_setting_tile.dart";
class RoomSettingsSectionsController(final String roomId)
@ -24,6 +37,7 @@ class RoomSettingsSectionsController(final String roomId)
RoomsController.provider.select((rooms) => rooms[roomId]),
);
final client = ref.read(ClientController.provider.notifier);
final roomsController = ref.read(RoomsController.provider.notifier);
// The room's own state, rather than metadata like a name made up from
// its members when it has none
@ -52,10 +66,106 @@ class RoomSettingsSectionsController(final String roomId)
final canSetAvatar = canSet(.avatar);
final canSetName = canSet(.name);
final canSetTopic = canSet(.topic);
final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias);
Future<void> setState(EventType type, Content content) => client.setState(
final joinRules = switch (stateContent(.joinRules)) {
final JoinRulesContent content => content,
_ => JoinRulesContent(joinRule: .invite),
};
final historyVisibility = switch (stateContent(.historyVisibility)) {
HistoryVisibilityContent(:final historyVisibility) => historyVisibility,
_ => HistoryVisibility.shared,
};
final canonicalAlias = switch (stateContent(.canonicalAlias)) {
final CanonicalAliasContent content => content,
_ => CanonicalAliasContent(),
};
final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]);
final localAliases =
ref.watch(LocalAliasesController.provider(roomId)).value ??
const IList<String>.empty();
final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([
?canonicalAlias.alias,
]).toISet();
final roomVersion = switch (stateContent(.create)) {
CreateContent(:final roomVersion) => int.tryParse(roomVersion),
_ => null,
};
bool versionAtLeast(int version) =>
roomVersion == null || roomVersion >= version;
final joinRuleOptions = [
JoinRule.public,
JoinRule.invite,
if (versionAtLeast(7)) JoinRule.knock,
if (versionAtLeast(8)) JoinRule.restricted,
if (versionAtLeast(10)) JoinRule.knockRestricted,
];
bool isRestricted(JoinRule rule) =>
rule == .restricted || rule == .knockRestricted;
final parentSpaces = ref
.watch(
SpaceEdgesController.provider.select(
(edges) => edges.entries
.where(
(entry) => entry.value.any((edge) => edge.childId == roomId),
)
.map((entry) => entry.key)
.toIList(),
),
)
.addAll(room?.state["m.space.parent"]?.keys ?? const [])
.toISet();
final allowedRooms = joinRules.allow
.map((condition) => condition.roomId)
.nonNulls
.toISet();
Future<void> setState(EventType type, Content content) async {
final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
);
if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId);
}
}
Future<void> setAliases(
String? canonical,
ISet<String> alternatives,
) async {
final stale = (await Future.wait(
alternatives.addAll([?canonical]).map((alias) async {
try {
return await client.resolveAlias(alias) == roomId ? null : alias;
} catch (_) {
return null;
}
}),
)).nonNulls.toISet();
bool kept(String alias) => !stale.contains(alias);
await setState(
.canonicalAlias,
CanonicalAliasContent(
alias: canonical != null && kept(canonical) ? canonical : null,
altAliases: alternatives.where(kept).toISet(),
),
);
}
Future<void> setAllowedRooms(ISet<String> roomIds) => setState(
.joinRules,
joinRules.copyWith(
allow: roomIds
.map((roomId) => AllowCondition(roomId: roomId, type: .membership))
.toISet(),
),
);
return .new({
"Room": .new([
@ -67,9 +177,14 @@ class RoomSettingsSectionsController(final String roomId)
title: "Avatar",
description: "The image shown for the room.",
icon: Icons.image,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
enabled: canSetAvatar,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
Future<void> save(Future<void> Function() action) =>
saving.run(() => action().onError(showError));
return ListTile(
enabled: canSetAvatar && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
@ -87,10 +202,11 @@ class RoomSettingsSectionsController(final String roomId)
IconButton(
tooltip: "Remove avatar",
icon: Icon(Icons.delete),
onPressed: () => setState(
.avatar,
const .new(),
).onError(showError),
onPressed: saving.active
? null
: () => save(
() => setState(.avatar, const .new()),
),
),
],
),
@ -100,9 +216,7 @@ class RoomSettingsSectionsController(final String roomId)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await context.withProgressSnackbar(
"Changing room avatar...",
() async {
await save(() async {
final content = await client.uploadMedia(
.new(path: file.path, encrypt: false),
);
@ -117,12 +231,12 @@ class RoomSettingsSectionsController(final String roomId)
} else {
throw Exception("The file isn't an image");
}
});
},
);
},
),
),
),
.new(
title: "Room Name",
description: "The name shown for the room.",
@ -165,6 +279,198 @@ class RoomSettingsSectionsController(final String roomId)
),
]),
),
.new(
title: "Access",
icon: Icons.key,
settings: .new([
.new(
title: "Join Rule",
description: "Who can join the room.",
icon: Icons.login,
builder: (title, description, icon) => DialogListTile<JoinRule>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: joinRules.joinRule,
options: joinRuleOptions,
getName: (rule) => switch (rule) {
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
},
onChanged: canSetJoinRules
? (rule) => setState(
.joinRules,
JoinRulesContent(
joinRule: rule,
allow: isRestricted(rule) && joinRules.allow.isEmpty
? .new(
parentSpaces.map(
(roomId) =>
.new(roomId: roomId, type: .membership),
),
)
: joinRules.allow,
),
).onError(showError)
: null,
),
),
if (isRestricted(joinRules.joinRule))
.new(
title: "Allowed Rooms",
description:
"Members of these rooms can join without an invite.",
icon: Icons.meeting_room,
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final saving = Saving.use();
final names = ref.watch(
RoomsController.provider.select(
(rooms) => allowedRooms
.map(
(roomId) =>
rooms[roomId]?.metadata?.name ?? roomId,
)
.toIList(),
),
);
return ListTile(
enabled: canSetJoinRules && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
names.isEmpty
? "None, so only invited users can join"
: names.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AllowedRoomsSheet(
roomId: roomId,
allowed: allowedRooms,
parentSpaces: parentSpaces,
onSave: (allowed) => saving.run(
() => setAllowedRooms(allowed).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Aliases",
description:
"The addresses the room advertises, and its main one.",
icon: Icons.alternate_email,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canSetAliases && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
switch ((
canonicalAlias.alias,
canonicalAlias.altAliases.length,
)) {
(null, 0) => description,
(final alias?, 0) => alias,
(final alias?, final others) =>
"$alias and $others more",
(null, _) => canonicalAlias.altAliases.join(", "),
},
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AliasesSheet(
aliases: knownAliases,
canonical: canonicalAlias.alias,
alternatives: canonicalAlias.altAliases,
onSave: (canonical, alternatives) => saving.run(
() => setAliases(
canonical,
alternatives,
).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Local Aliases",
description: "Addresses for the room on your server.",
icon: Icons.dns,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(
localAliases.isEmpty
? description
: localAliases.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => LocalAliasesSheet(
roomId,
onRemoved: canSetAliases
? (alias) async {
if (published.contains(alias)) {
await setAliases(
canonicalAlias.alias,
canonicalAlias.altAliases,
);
}
}
: null,
),
),
),
),
),
.new(
title: "History Visibility",
description:
"Which messages from before joining members can read.",
icon: Icons.history,
builder: (title, description, icon) =>
DialogListTile<HistoryVisibility>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: historyVisibility,
options: [.shared, .invited, .joined],
getName: (visibility) => switch (visibility) {
.shared => "All events",
.invited => "After invite",
.joined => "After join",
.worldReadable => "Anyone",
},
onChanged: canSetHistoryVisibility
? (visibility) => setState(
.historyVisibility,
HistoryVisibilityContent(
historyVisibility: visibility,
),
).onError(showError)
: null,
),
),
]),
),
]),
});
}

View file

@ -1,3 +1,5 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
@ -128,6 +130,31 @@ class RoomsController extends Notifier<IMap<String, Room>> {
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
}
/// Waits for a sent state event to become the room's state, which happens
/// once it comes down sync. Gives up after a while, e.g. if another event
/// replaced it first.
Future<void> awaitState(
String roomId,
String type,
String eventId, {
String stateKey = "",
}) {
bool arrived(IMap<String, Room> rooms) =>
switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false,
};
if (arrived(state)) return .value();
final completer = Completer<void>();
final removeListener = listenSelf((_, rooms) {
if (arrived(rooms) && !completer.isCompleted) completer.complete();
});
return completer.future
.timeout(const .new(seconds: 10), onTimeout: () {})
.whenComplete(removeListener);
}
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
RoomsController.new,
);

View file

@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
required Widget Function(T value) data,
Widget Function() loading = Loading.new,
bool skipLoadingOnRefresh = false,
bool skipLoadingOnReload = false,
}) => when(
data: data,
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
loading: loading,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipLoadingOnReload: skipLoadingOnReload,
);
}

View file

@ -0,0 +1,25 @@
import "dart:async";
import "package:flutter_hooks/flutter_hooks.dart";
final class Saving({
required final bool active,
required final Future<void> Function(FutureOr<void> Function() action) run,
}) {
factory use() {
final context = useContext();
final active = useState(false);
return .new(
active: active.value,
run: (action) async {
active.value = true;
try {
await action();
} finally {
if (context.mounted) active.value = false;
}
},
);
}
}

View file

@ -9,7 +9,7 @@ sealed class CanonicalAliasContent extends Content
with _$CanonicalAliasContent {
CanonicalAliasContent._();
factory CanonicalAliasContent({
String? alias,
@JsonKey(includeIfNull: false) String? alias,
@Default(ISet.empty()) ISet<String> altAliases,
}) = _CanonicalAliasContent;

View file

@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
JoinRulesContent._();
factory JoinRulesContent({
required JoinRule joinRule,
@Default(IList.empty()) IList<AllowCondition> allow,
@Default(ISet.empty()) ISet<AllowCondition> allow,
}) = _JoinRulesContent;
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>

View file

@ -53,6 +53,7 @@ class const SettingsCategoryPage(
return ref
.watch(sectionsProvider)
.betterWhen(
skipLoadingOnReload: true,
data: (sections) => Scaffold(
appBar: AppBar(
title: Text(sections.values.flattenedToList[index].title),

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,19 +11,25 @@ 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,
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,
onChanged: onChanged == null ? null : change,
value: value,
options: options,
),
@ -28,12 +37,18 @@ final class const DialogListTile<T>({
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: Chip(
trailing: ConstrainedBox(
constraints: const .new(maxWidth: 160),
child: Chip(
label: Text(
value == null ? "None" : getName(value as T),
value is T ? getName(value as T) : "None",
overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null,
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,14 +12,18 @@ 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,
Widget build(BuildContext context) {
final saving = Saving.use();
Future<void> change(String value) => saving.run(() => onChanged!(value));
return ListTile(
enabled: onChanged != null && !saving.active,
leading: icon,
title: Text(title),
subtitle: Text(
@ -31,7 +38,7 @@ class const TextSettingTile({
void save() {
Navigator.of(context).pop();
onChanged!(controller.text.trim());
change(controller.text.trim());
}
return SheetLayout(
@ -59,4 +66,5 @@ class const TextSettingTile({
),
),
);
}
}

View file

@ -9,22 +9,34 @@ class const SheetLayout({
@override
Widget build(BuildContext context) => Padding(
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (title case final title?)
Padding(
padding: const .fromLTRB(24, 24, 24, 0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.headlineSmall!,
child: title,
),
),
Flexible(
child: SingleChildScrollView(
padding: const .all(24),
padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
spacing: 12,
children: [
if (title case final title?)
DefaultTextStyle(
style: Theme.of(context).textTheme.headlineSmall!,
child: title,
children: children,
),
),
),
Padding(
padding: const .fromLTRB(24, 0, 24, 24),
child: Row(mainAxisAlignment: .end, spacing: 8, children: actions),
),
...children,
Row(mainAxisAlignment: .end, spacing: 8, children: actions),
],
),
),
);
}