add room access settings
This commit is contained in:
parent
8e51852ae3
commit
b390dbbbe1
17 changed files with 974 additions and 128 deletions
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
28
lib/controllers/local_aliases.dart
Normal file
28
lib/controllers/local_aliases.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
25
lib/helpers/hooks/saving.dart
Normal file
25
lib/helpers/hooks/saving.dart
Normal 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
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,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,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({
|
|||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue