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>(),
|
?.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 =>
|
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||||
.fromJson(await _sendCommand("get_versions"));
|
.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:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/client.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/power_level.dart";
|
||||||
import "package:nexus/controllers/rooms.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/file_dialog_helper.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/models/content/avatar.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/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/message.dart";
|
||||||
import "package:nexus/models/content/name.dart";
|
import "package:nexus/models/content/name.dart";
|
||||||
import "package:nexus/models/content/topic.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/models/settings_category.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.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";
|
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
||||||
|
|
||||||
class RoomSettingsSectionsController(final String roomId)
|
class RoomSettingsSectionsController(final String roomId)
|
||||||
|
|
@ -24,6 +37,7 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||||
);
|
);
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
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
|
// The room's own state, rather than metadata like a name made up from
|
||||||
// its members when it has none
|
// its members when it has none
|
||||||
|
|
@ -52,10 +66,106 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
final canSetAvatar = canSet(.avatar);
|
final canSetAvatar = canSet(.avatar);
|
||||||
final canSetName = canSet(.name);
|
final canSetName = canSet(.name);
|
||||||
final canSetTopic = canSet(.topic);
|
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),
|
.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({
|
return .new({
|
||||||
"Room": .new([
|
"Room": .new([
|
||||||
|
|
@ -67,9 +177,14 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
title: "Avatar",
|
title: "Avatar",
|
||||||
description: "The image shown for the room.",
|
description: "The image shown for the room.",
|
||||||
icon: Icons.image,
|
icon: Icons.image,
|
||||||
builder: (title, description, icon) => Builder(
|
builder: (title, description, icon) => HookBuilder(
|
||||||
builder: (context) => ListTile(
|
builder: (context) {
|
||||||
enabled: canSetAvatar,
|
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),
|
leading: Icon(icon),
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
|
|
@ -87,10 +202,11 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Remove avatar",
|
tooltip: "Remove avatar",
|
||||||
icon: Icon(Icons.delete),
|
icon: Icon(Icons.delete),
|
||||||
onPressed: () => setState(
|
onPressed: saving.active
|
||||||
.avatar,
|
? null
|
||||||
const .new(),
|
: () => save(
|
||||||
).onError(showError),
|
() => setState(.avatar, const .new()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -100,9 +216,7 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
.pickFile(images: true);
|
.pickFile(images: true);
|
||||||
if (file == null || !context.mounted) return;
|
if (file == null || !context.mounted) return;
|
||||||
|
|
||||||
await context.withProgressSnackbar(
|
await save(() async {
|
||||||
"Changing room avatar...",
|
|
||||||
() async {
|
|
||||||
final content = await client.uploadMedia(
|
final content = await client.uploadMedia(
|
||||||
.new(path: file.path, encrypt: false),
|
.new(path: file.path, encrypt: false),
|
||||||
);
|
);
|
||||||
|
|
@ -117,12 +231,12 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
} else {
|
} else {
|
||||||
throw Exception("The file isn't an image");
|
throw Exception("The file isn't an image");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
.new(
|
.new(
|
||||||
title: "Room Name",
|
title: "Room Name",
|
||||||
description: "The name shown for the room.",
|
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:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.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));
|
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>>(
|
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
|
||||||
RoomsController.new,
|
RoomsController.new,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
|
||||||
required Widget Function(T value) data,
|
required Widget Function(T value) data,
|
||||||
Widget Function() loading = Loading.new,
|
Widget Function() loading = Loading.new,
|
||||||
bool skipLoadingOnRefresh = false,
|
bool skipLoadingOnRefresh = false,
|
||||||
|
bool skipLoadingOnReload = false,
|
||||||
}) => when(
|
}) => when(
|
||||||
data: data,
|
data: data,
|
||||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||||
loading: loading,
|
loading: loading,
|
||||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
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 {
|
with _$CanonicalAliasContent {
|
||||||
CanonicalAliasContent._();
|
CanonicalAliasContent._();
|
||||||
factory CanonicalAliasContent({
|
factory CanonicalAliasContent({
|
||||||
String? alias,
|
@JsonKey(includeIfNull: false) String? alias,
|
||||||
@Default(ISet.empty()) ISet<String> altAliases,
|
@Default(ISet.empty()) ISet<String> altAliases,
|
||||||
}) = _CanonicalAliasContent;
|
}) = _CanonicalAliasContent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
|
||||||
JoinRulesContent._();
|
JoinRulesContent._();
|
||||||
factory JoinRulesContent({
|
factory JoinRulesContent({
|
||||||
required JoinRule joinRule,
|
required JoinRule joinRule,
|
||||||
@Default(IList.empty()) IList<AllowCondition> allow,
|
@Default(ISet.empty()) ISet<AllowCondition> allow,
|
||||||
}) = _JoinRulesContent;
|
}) = _JoinRulesContent;
|
||||||
|
|
||||||
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class const SettingsCategoryPage(
|
||||||
return ref
|
return ref
|
||||||
.watch(sectionsProvider)
|
.watch(sectionsProvider)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
data: (sections) => Scaffold(
|
data: (sections) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(sections.values.flattenedToList[index].title),
|
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:material_ui/material_ui.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.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";
|
import "package:nexus/widgets/settings/radio_sheet.dart";
|
||||||
|
|
||||||
final class const DialogListTile<T>({
|
final class const DialogListTile<T>({
|
||||||
|
|
@ -8,19 +11,25 @@ final class const DialogListTile<T>({
|
||||||
required final Icon icon,
|
required final Icon icon,
|
||||||
required final String title,
|
required final String title,
|
||||||
required final List<T> options,
|
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,
|
required final String Function(T option) getName,
|
||||||
final Widget? subtitle,
|
final Widget? subtitle,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ListTile(
|
Widget build(BuildContext context) {
|
||||||
enabled: onChanged != null,
|
final saving = Saving.use();
|
||||||
|
Future<void> change(T value) => saving.run(() => onChanged!(value));
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
enabled: onChanged != null && !saving.active,
|
||||||
onTap: () => context.showAdaptiveSheet(
|
onTap: () => context.showAdaptiveSheet(
|
||||||
(context) => RadioSheet<T>(
|
(context) => RadioSheet<T>(
|
||||||
title: title,
|
title: title,
|
||||||
getName: getName,
|
getName: getName,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged == null ? null : change,
|
||||||
value: value,
|
value: value,
|
||||||
options: options,
|
options: options,
|
||||||
),
|
),
|
||||||
|
|
@ -28,12 +37,18 @@ final class const DialogListTile<T>({
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
leading: icon,
|
leading: icon,
|
||||||
trailing: Chip(
|
trailing: ConstrainedBox(
|
||||||
|
constraints: const .new(maxWidth: 160),
|
||||||
|
child: Chip(
|
||||||
label: Text(
|
label: Text(
|
||||||
value == null ? "None" : getName(value as T),
|
value is T ? getName(value as T) : "None",
|
||||||
overflow: TextOverflow.ellipsis,
|
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 {
|
}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SheetLayout(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -20,7 +21,7 @@ final class const RadioSheet<T>({
|
||||||
if (onChanged != null)
|
if (onChanged != null)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (mutValue.value != null) onChanged!(mutValue.value as T);
|
if (chosen.value case (final picked,)) onChanged!(picked);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text("OK"),
|
child: Text("OK"),
|
||||||
|
|
@ -28,8 +29,11 @@ final class const RadioSheet<T>({
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
RadioGroup<T>(
|
RadioGroup<T>(
|
||||||
groupValue: mutValue.value ?? value,
|
groupValue: switch (chosen.value) {
|
||||||
onChanged: (value) => mutValue.value = value ?? mutValue.value,
|
(final picked,) => picked,
|
||||||
|
null => value,
|
||||||
|
},
|
||||||
|
onChanged: (value) => chosen.value = (value as T,),
|
||||||
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ class const SettingsLayout({
|
||||||
body: ref
|
body: ref
|
||||||
.watch(sectionsProvider)
|
.watch(sectionsProvider)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
data: (sections) => categoriesArePages
|
data: (sections) => categoriesArePages
|
||||||
? CustomScrollView(
|
? CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.dart";
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
class const TextSettingTile({
|
class const TextSettingTile({
|
||||||
|
|
@ -9,14 +12,18 @@ class const TextSettingTile({
|
||||||
required final String description,
|
required final String description,
|
||||||
required final String? value,
|
required final String? value,
|
||||||
|
|
||||||
/// Disables the tile if null
|
/// Disables the tile if null, and while a returned future runs.
|
||||||
required final void Function(String value)? onChanged,
|
required final FutureOr<void> Function(String value)? onChanged,
|
||||||
final bool multiline = false,
|
final bool multiline = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
Widget build(BuildContext context) {
|
||||||
enabled: onChanged != null,
|
final saving = Saving.use();
|
||||||
|
Future<void> change(String value) => saving.run(() => onChanged!(value));
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
enabled: onChanged != null && !saving.active,
|
||||||
leading: icon,
|
leading: icon,
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
|
|
@ -31,7 +38,7 @@ class const TextSettingTile({
|
||||||
|
|
||||||
void save() {
|
void save() {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
onChanged!(controller.text.trim());
|
change(controller.text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
return SheetLayout(
|
return SheetLayout(
|
||||||
|
|
@ -60,3 +67,4 @@ class const TextSettingTile({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,34 @@ class const SheetLayout({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
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(
|
child: SingleChildScrollView(
|
||||||
padding: const .all(24),
|
padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
crossAxisAlignment: .stretch,
|
crossAxisAlignment: .stretch,
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: children,
|
||||||
if (title case final title?)
|
),
|
||||||
DefaultTextStyle(
|
),
|
||||||
style: Theme.of(context).textTheme.headlineSmall!,
|
),
|
||||||
child: title,
|
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