diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index ede7e11..fef1c16 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -377,6 +377,28 @@ class ClientController extends AsyncNotifier { ?.cast(), ); + Future> getLocalAliases(String roomId) async => IList( + ((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] + as List?) + ?.cast(), + ); + + Future createAlias(String roomId, String alias) => + _sendCommand("create_alias", {"room_id": roomId, "alias": alias}); + + Future deleteAlias(String alias) => + _sendCommand("delete_alias", {"alias": alias}); + + /// The room [alias] points to, or null if it doesn't exist. + Future 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 getSpecVersions() async => .fromJson(await _sendCommand("get_versions")); diff --git a/lib/controllers/local_aliases.dart b/lib/controllers/local_aliases.dart new file mode 100644 index 0000000..e80d4bb --- /dev/null +++ b/lib/controllers/local_aliases.dart @@ -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> { + @override + Future> build() => + ref.read(ClientController.provider.notifier).getLocalAliases(roomId); + + Future add(String alias) async { + await ref + .read(ClientController.provider.notifier) + .createAlias(roomId, alias); + ref.invalidateSelf(); + } + + Future remove(String alias) async { + await ref.read(ClientController.provider.notifier).deleteAlias(alias); + ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + LocalAliasesController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index b4a41f4..33a3d4a 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -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,9 +66,105 @@ 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 setState(EventType type, Content content) => client.setState( - .new(roomId: roomId, type: type.type, stateKey: "", content: content), + 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.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 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 setAliases( + String? canonical, + ISet 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 setAllowedRooms(ISet roomIds) => setState( + .joinRules, + joinRules.copyWith( + allow: roomIds + .map((roomId) => AllowCondition(roomId: roomId, type: .membership)) + .toISet(), + ), ); return .new({ @@ -67,42 +177,46 @@ 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, - leading: Icon(icon), - title: Text(title), - subtitle: Text(description), - trailing: Row( - mainAxisSize: .min, - spacing: 8, - children: [ - AvatarOrHash( - avatar, - name ?? "Unnamed Room", - dimension: 40, - fallback: Icon(Icons.numbers), - ), - if (avatar != null && canSetAvatar) - IconButton( - tooltip: "Remove avatar", - icon: Icon(Icons.delete), - onPressed: () => setState( - .avatar, - const .new(), - ).onError(showError), - ), - ], - ), - onTap: () async { - final file = await ProviderScope.containerOf(context) - .read(FileDialogHelper.provider) - .pickFile(images: true); - if (file == null || !context.mounted) return; + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + Future save(Future Function() action) => + saving.run(() => action().onError(showError)); - await context.withProgressSnackbar( - "Changing room avatar...", - () async { + return ListTile( + enabled: canSetAvatar && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + AvatarOrHash( + avatar, + name ?? "Unnamed Room", + dimension: 40, + fallback: Icon(Icons.numbers), + ), + if (avatar != null && canSetAvatar) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => save( + () => setState(.avatar, const .new()), + ), + ), + ], + ), + onTap: () async { + final file = await ProviderScope.containerOf(context) + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await save(() async { final content = await client.uploadMedia( .new(path: file.path, encrypt: false), ); @@ -117,10 +231,10 @@ class RoomSettingsSectionsController(final String roomId) } else { throw Exception("The file isn't an image"); } - }, - ); - }, - ), + }); + }, + ); + }, ), ), .new( @@ -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( + 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( + 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, + ), + ), + ]), + ), ]), }); } diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index f846718..5370bcf 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -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> { 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 awaitState( + String roomId, + String type, + String eventId, { + String stateKey = "", + }) { + bool arrived(IMap 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(); + 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.new, ); diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index beaae85..e054d02 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -8,10 +8,12 @@ extension BetterWhen on AsyncValue { 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, ); } diff --git a/lib/helpers/hooks/saving.dart b/lib/helpers/hooks/saving.dart new file mode 100644 index 0000000..3a0faef --- /dev/null +++ b/lib/helpers/hooks/saving.dart @@ -0,0 +1,25 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; + +final class Saving({ + required final bool active, + required final Future Function(FutureOr 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; + } + }, + ); + } +} diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 1a35d92..6fb2829 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -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 altAliases, }) = _CanonicalAliasContent; diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 3f1c17b..8062657 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent { JoinRulesContent._(); factory JoinRulesContent({ required JoinRule joinRule, - @Default(IList.empty()) IList allow, + @Default(ISet.empty()) ISet allow, }) = _JoinRulesContent; factory JoinRulesContent.fromJson(Map json) => diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart index db22367..69513b8 100644 --- a/lib/widgets/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -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), diff --git a/lib/widgets/settings/aliases_sheet.dart b/lib/widgets/settings/aliases_sheet.dart new file mode 100644 index 0000000..d517f98 --- /dev/null +++ b/lib/widgets/settings/aliases_sheet.dart @@ -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 aliases, + required final String? canonical, + required final ISet alternatives, + + /// The canonical alias, and the other published ones. + required final void Function(String? canonical, ISet 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); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/allowed_rooms_sheet.dart b/lib/widgets/settings/allowed_rooms_sheet.dart new file mode 100644 index 0000000..c59c527 --- /dev/null +++ b/lib/widgets/settings/allowed_rooms_sheet.dart @@ -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 allowed, + + /// Suggested first, as they're the usual choice. + required final ISet parentSpaces, + required final void Function(ISet allowed) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(allowed); + final error = useState(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 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), + ); + }, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index d802d7b..b31182f 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -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({ @@ -8,32 +11,44 @@ final class const DialogListTile({ required final Icon icon, required final String title, required final List options, - required final void Function(T value)? onChanged, + + /// While a returned future runs, the tile is disabled. + required final FutureOr 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( - title: title, - getName: getName, - onChanged: onChanged, - value: value, - options: options, + Widget build(BuildContext context) { + final saving = Saving.use(); + Future change(T value) => saving.run(() => onChanged!(value)); + + return ListTile( + enabled: onChanged != null && !saving.active, + onTap: () => context.showAdaptiveSheet( + (context) => RadioSheet( + 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, + ), + ), ), - ), - ); + ); + } } diff --git a/lib/widgets/settings/local_aliases_sheet.dart b/lib/widgets/settings/local_aliases_sheet.dart new file mode 100644 index 0000000..def7db9 --- /dev/null +++ b/lib/widgets/settings/local_aliases_sheet.dart @@ -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 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(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, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/radio_sheet.dart b/lib/widgets/settings/radio_sheet.dart index 051b67a..9f9512e 100644 --- a/lib/widgets/settings/radio_sheet.dart +++ b/lib/widgets/settings/radio_sheet.dart @@ -12,7 +12,8 @@ final class const RadioSheet({ }) extends HookWidget { @override Widget build(BuildContext context) { - final mutValue = useState(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({ 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({ ], children: [ RadioGroup( - 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, diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart index ca770d6..7f6c527 100644 --- a/lib/widgets/settings/settings_layout.dart +++ b/lib/widgets/settings/settings_layout.dart @@ -120,6 +120,7 @@ class const SettingsLayout({ body: ref .watch(sectionsProvider) .betterWhen( + skipLoadingOnReload: true, data: (sections) => categoriesArePages ? CustomScrollView( slivers: [ diff --git a/lib/widgets/settings/text_setting_tile.dart b/lib/widgets/settings/text_setting_tile.dart index d172bb3..6f97eb0 100644 --- a/lib/widgets/settings/text_setting_tile.dart +++ b/lib/widgets/settings/text_setting_tile.dart @@ -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 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 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), + ), + ], + ); + }, + ), + ), + ); + } } diff --git a/lib/widgets/sheet_layout.dart b/lib/widgets/sheet_layout.dart index 0ce98d1..9a15a1d 100644 --- a/lib/widgets/sheet_layout.dart +++ b/lib/widgets/sheet_layout.dart @@ -9,22 +9,34 @@ class const SheetLayout({ @override Widget build(BuildContext context) => Padding( padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: SingleChildScrollView( - padding: const .all(24), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - spacing: 12, - children: [ - if (title case final title?) - DefaultTextStyle( + 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, ), - ...children, - Row(mainAxisAlignment: .end, spacing: 8, children: actions), - ], - ), + ), + Flexible( + child: SingleChildScrollView( + padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: 12, + children: children, + ), + ), + ), + Padding( + padding: const .fromLTRB(24, 0, 24, 24), + child: Row(mainAxisAlignment: .end, spacing: 8, children: actions), + ), + ], ), ); }