add room access settings
This commit is contained in:
parent
8e51852ae3
commit
b390dbbbe1
7 changed files with 974 additions and 128 deletions
78
lib/widgets/settings/aliases_sheet.dart
Normal file
78
lib/widgets/settings/aliases_sheet.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/widgets/settings/allowed_rooms_sheet.dart
Normal file
210
lib/widgets/settings/allowed_rooms_sheet.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
lib/widgets/settings/local_aliases_sheet.dart
Normal file
107
lib/widgets/settings/local_aliases_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ class const SettingsLayout({
|
|||
body: ref
|
||||
.watch(sectionsProvider)
|
||||
.betterWhen(
|
||||
skipLoadingOnReload: true,
|
||||
data: (sections) => categoriesArePages
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue