reorganize and rename widgets
This commit is contained in:
parent
05d25869ef
commit
d5f29dc344
10 changed files with 568 additions and 547 deletions
54
lib/widgets/rooms/add_directory_server_sheet.dart
Normal file
54
lib/widgets/rooms/add_directory_server_sheet.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
|
||||
class const AddDirectoryServerSheet({
|
||||
required final ISet<String> existing,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController();
|
||||
final text = useValueListenable(controller).text;
|
||||
final server = text.trim().toLowerCase();
|
||||
final error = server.isEmpty
|
||||
? null
|
||||
: !server.isValidServerName
|
||||
? "Enter a server name, without https:// or a path"
|
||||
: existing.contains(server)
|
||||
? "This server has already been added"
|
||||
: null;
|
||||
final valid = server.isNotEmpty && error == null;
|
||||
|
||||
void submit() {
|
||||
if (valid) Navigator.of(context).pop(server);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: const Text("Add Server"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(onPressed: valid ? submit : null, child: const Text("Add")),
|
||||
],
|
||||
children: [
|
||||
const Text("Enter a server to browse the public rooms of."),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: .url,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: .new(
|
||||
labelText: "Server name",
|
||||
hintText: "matrix.org",
|
||||
errorText: error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
181
lib/widgets/rooms/add_space_rooms_sheet.dart
Normal file
181
lib/widgets/rooms/add_space_rooms_sheet.dart
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_hierarchy.dart";
|
||||
import "package:nexus/controllers/via.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/space_child.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||
|
||||
/// Chooses rooms to add to the space [spaceId], then adds them.
|
||||
class const AddSpaceRoomsSheet(
|
||||
final String spaceId, {
|
||||
required final String spaceName,
|
||||
|
||||
/// The rooms already in the space, which aren't offered.
|
||||
required final ISet<String> existing,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chosen = useState(const ISet<String>.empty());
|
||||
final error = useState<String?>(null);
|
||||
final resolving = useState(false);
|
||||
final controller = useTextEditingController();
|
||||
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;
|
||||
|
||||
final suggestions = rooms.entries
|
||||
.where((entry) => entry.key != spaceId && !existing.contains(entry.key))
|
||||
.sortedBy((entry) => entry.value.name?.toLowerCase() ?? "")
|
||||
.map((entry) => entry.key)
|
||||
.toIList();
|
||||
|
||||
void add(String id) {
|
||||
if (id == spaceId) {
|
||||
error.value = "A space can't contain itself";
|
||||
return;
|
||||
}
|
||||
if (existing.contains(id)) {
|
||||
error.value = "This room is already in the space";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after the sheet closes, so it can't use [ref]
|
||||
Future<void> save(ProviderContainer container, ISet<String> roomIds) async {
|
||||
final client = container.read(ClientController.provider.notifier);
|
||||
final joined = container.read(RoomsController.provider);
|
||||
final ownServer = container
|
||||
.read(ClientStateController.provider)
|
||||
?.userId
|
||||
?.serverName;
|
||||
|
||||
await Future.wait(
|
||||
roomIds.map(
|
||||
(roomId) => client.setState(
|
||||
.new(
|
||||
roomId: spaceId,
|
||||
type: EventType.spaceChild.type,
|
||||
stateKey: roomId,
|
||||
content: SpaceChildContent(
|
||||
via: switch (joined[roomId]) {
|
||||
final room? => container.read(ViaController.provider(room)),
|
||||
// Rooms the user isn't in can only be routed through their
|
||||
// own server
|
||||
null => ISet([?ownServer]),
|
||||
}.toIList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
container.invalidate(SpaceHierarchyController.provider(spaceId));
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Add Rooms"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: chosen.value.isEmpty
|
||||
? null
|
||||
: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final roomIds = chosen.value;
|
||||
SpaceHierarchyController.addChildrenMutation(spaceId).run(
|
||||
container,
|
||||
(_) => context.withProgressSnackbar(
|
||||
"Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...",
|
||||
() => save(container, roomIds),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("Add"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text("Choose rooms to add to \"$spaceName\"."),
|
||||
SearchChooser(
|
||||
controller: controller,
|
||||
chosen: chosen.value,
|
||||
options: suggestions,
|
||||
nameOf: nameOf,
|
||||
avatarOf: (id) => AvatarOrHash(
|
||||
rooms[id]?.avatar,
|
||||
nameOf(id),
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
subtitleOf: (id) => rooms[id]?.name == null ? null : id,
|
||||
onAdded: add,
|
||||
onRemoved: (id) => chosen.value = chosen.value.remove(id),
|
||||
onSubmitted: (text, topMatch) {
|
||||
if (text.startsWith(RegExp("[!#]"))) {
|
||||
addRoom(text).onError(showError);
|
||||
} else if (topMatch != null) {
|
||||
add(topMatch);
|
||||
} else {
|
||||
error.value = "Enter a room ID or alias";
|
||||
}
|
||||
},
|
||||
onChanged: (_) => error.value = null,
|
||||
label: "Add room",
|
||||
hint: "Search, or enter a room ID or alias",
|
||||
removeTooltip: "Remove room",
|
||||
error: error.value,
|
||||
loading: resolving.value,
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/widgets/rooms/create_room_sheet.dart
Normal file
149
lib/widgets/rooms/create_room_sheet.dart
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/helpers/extensions/create_room.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
|
||||
class const CreateRoomSheet({super.key}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverName = ref
|
||||
.watch(ClientStateController.provider)
|
||||
?.userId
|
||||
?.serverName;
|
||||
final nameController = useTextEditingController();
|
||||
final topicController = useTextEditingController();
|
||||
final aliasController = useTextEditingController();
|
||||
final name = useValueListenable(nameController).text.trim();
|
||||
final isPublic = useState(false);
|
||||
final isPublished = useState(false);
|
||||
final isEncrypted = useState(false);
|
||||
final isSpace = useState(false);
|
||||
|
||||
void submit() {
|
||||
if (name.isEmpty) return;
|
||||
|
||||
final topic = topicController.text.trim();
|
||||
final alias = aliasController.text.trim();
|
||||
|
||||
context.createRoom(
|
||||
.new(
|
||||
name: name,
|
||||
topic: topic.isEmpty ? null : topic,
|
||||
roomAliasName: isPublic.value && alias.isNotEmpty ? alias : null,
|
||||
visibility: isPublic.value && isPublished.value ? .public : .private,
|
||||
preset: isPublic.value ? .publicChat : .privateChat,
|
||||
initialState: isEncrypted.value && !isSpace.value
|
||||
? .new([.encryption])
|
||||
: const .empty(),
|
||||
creationContent: isSpace.value ? .new({"type": "m.space"}) : null,
|
||||
),
|
||||
name: name,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
AnimatedSize animated({
|
||||
required bool visible,
|
||||
required Widget child,
|
||||
double gap = 12,
|
||||
}) => AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: visible
|
||||
? Padding(
|
||||
padding: .only(top: gap),
|
||||
child: child,
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
);
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Create a ${isSpace.value ? "Space" : "Room"}"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: name.isEmpty ? null : submit,
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
textCapitalization: .sentences,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: const .new(labelText: "Name"),
|
||||
),
|
||||
TextField(
|
||||
controller: topicController,
|
||||
textCapitalization: .sentences,
|
||||
decoration: const .new(labelText: "Topic (optional)"),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.public),
|
||||
title: const Text("Public"),
|
||||
subtitle: const Text("Anyone can join"),
|
||||
value: isPublic.value,
|
||||
onChanged: (value) {
|
||||
isPublic.value = value;
|
||||
if (value) isEncrypted.value = false;
|
||||
},
|
||||
),
|
||||
animated(
|
||||
visible: isPublic.value,
|
||||
child: TextField(
|
||||
controller: aliasController,
|
||||
decoration: .new(
|
||||
labelText: "Canonical alias (optional)",
|
||||
prefixText: "# ",
|
||||
suffixText: serverName == null ? null : ":$serverName",
|
||||
hintText: "my-room",
|
||||
),
|
||||
),
|
||||
),
|
||||
animated(
|
||||
visible: isPublic.value,
|
||||
child: SwitchListTile(
|
||||
secondary: const Icon(Icons.travel_explore),
|
||||
title: const Text("Publish to room directory"),
|
||||
subtitle: const Text("List it in your homeserver's directory"),
|
||||
value: isPublished.value,
|
||||
onChanged: (value) => isPublished.value = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.workspaces),
|
||||
title: const Text("Space"),
|
||||
subtitle: const Text("A group of rooms"),
|
||||
value: isSpace.value,
|
||||
onChanged: (value) => isSpace.value = value,
|
||||
),
|
||||
animated(
|
||||
visible: !isSpace.value,
|
||||
child: SwitchListTile(
|
||||
secondary: const Icon(Icons.lock),
|
||||
title: const Text("Encrypted"),
|
||||
subtitle: const Text("Can't be turned off later"),
|
||||
value: isEncrypted.value,
|
||||
onChanged: (value) => isEncrypted.value = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/widgets/rooms/join_sheet.dart
Normal file
61
lib/widgets/rooms/join_sheet.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/room_summary.dart";
|
||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
||||
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
|
||||
|
||||
class const JoinSheet(final WidgetRef ref, {super.key}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roomAlias = useTextEditingController();
|
||||
|
||||
Future<void> onJoin() async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
final linkInfo = roomAlias.text.linkInfo;
|
||||
final via = linkInfo?.via ?? const .empty();
|
||||
final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text;
|
||||
|
||||
context.showAdaptiveSheet(
|
||||
(context) => Consumer(
|
||||
builder: (context, ref, _) => switch (ref.watch(
|
||||
RoomSummaryController.provider(
|
||||
.new(roomIdOrAlias: roomIdOrAlias, via: via),
|
||||
),
|
||||
)) {
|
||||
AsyncData(:final value) => RoomSummarySheet(value, via: via),
|
||||
AsyncError _ || AsyncLoading _ => RoomSummarySheet(
|
||||
.new(roomId: roomIdOrAlias),
|
||||
via: via,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Join a Room"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: onJoin, child: Text("Join")),
|
||||
],
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text("Enter the room alias, Matrix URI, or Matrix.to link."),
|
||||
SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: roomAlias,
|
||||
decoration: .new(hintText: "#room:server.name"),
|
||||
onEditingComplete: onJoin,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/widgets/rooms/knock_sheet.dart
Normal file
51
lib/widgets/rooms/knock_sheet.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
|
||||
/// Asks the moderators of a room to let the user in, with an optional reason.
|
||||
///
|
||||
/// Pops with true once the request has been sent.
|
||||
class const KnockSheet(
|
||||
final String roomId, {
|
||||
required final String name,
|
||||
final IList<String> via = const .empty(),
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reason = useTextEditingController();
|
||||
|
||||
void onKnock() {
|
||||
context.knockRoom(
|
||||
.new(
|
||||
roomIdOrAlias: roomId,
|
||||
via: via,
|
||||
reason: reason.text.isEmpty ? null : reason.text,
|
||||
),
|
||||
name: name,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Knock on $name"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: onKnock, child: Text("Knock")),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"This room requires knocking to join. Its moderators will be able to see your knock and let you in.",
|
||||
),
|
||||
TextField(
|
||||
textCapitalization: .sentences,
|
||||
controller: reason,
|
||||
decoration: .new(labelText: "Reason (optional)"),
|
||||
onEditingComplete: onKnock,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/widgets/rooms/room_directory_card.dart
Normal file
130
lib/widgets/rooms/room_directory_card.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
import "package:nexus/helpers/extensions/room_summary_display.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/rooms/knock_sheet.dart";
|
||||
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
|
||||
|
||||
class const RoomDirectoryCard(
|
||||
final RoomSummary summary, {
|
||||
required final IList<String> via,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final joined = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms.containsKey(summary.roomId),
|
||||
),
|
||||
);
|
||||
final mustKnock = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => summary.mustKnock(
|
||||
summary.allowedRoomIds.where(rooms.containsKey).toISet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final name = summary.displayName;
|
||||
|
||||
return Card.filled(
|
||||
clipBehavior: .antiAlias,
|
||||
margin: .zero,
|
||||
child: InkWell(
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => RoomSummarySheet(summary, via: joined ? null : via),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const .all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
AvatarOrHash(
|
||||
summary.avatarUrl,
|
||||
name,
|
||||
dimension: 48,
|
||||
fallback: Icon(summary.icon),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (summary.details case final details
|
||||
when details.isNotEmpty)
|
||||
Text(
|
||||
details,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
summary.topic ?? summary.canonicalAlias ?? "",
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: .centerRight,
|
||||
child: joined
|
||||
? FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
Navigator.of(context).pop();
|
||||
await container.focusRoom(summary.roomId);
|
||||
container
|
||||
.read(SidebarPageController.provider.notifier)
|
||||
.set(.rooms);
|
||||
},
|
||||
child: const Text("Open"),
|
||||
)
|
||||
: mustKnock
|
||||
? FilledButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) =>
|
||||
KnockSheet(summary.roomId, name: name, via: via),
|
||||
),
|
||||
child: const Text("Knock"),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: () => context.joinRoom(
|
||||
.new(roomIdOrAlias: summary.roomId, via: via),
|
||||
name: name,
|
||||
),
|
||||
child: const Text("Join"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
lib/widgets/rooms/room_menu_button.dart
Normal file
200
lib/widgets/rooms/room_menu_button.dart
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_leave_room_sheet.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/models/room_notification_level.dart";
|
||||
import "package:nexus/controllers/room_settings_sections.dart";
|
||||
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||
import "package:nexus/controllers/via.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
final class const RoomMenuButton(
|
||||
final Room? room, {
|
||||
final IList<Room> children = const .empty(),
|
||||
final VoidCallback? onOpenSearch,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final danger = Theme.of(context).colorScheme.error;
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
return AdaptiveMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: "Room options",
|
||||
itemBuilder: (_) => [
|
||||
if (onOpenSearch != null)
|
||||
PopupMenuItem(
|
||||
onTap: onOpenSearch,
|
||||
child: ListTile(leading: Icon(Icons.search), title: Text("Search")),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
if (room != null) await client.markRead(room!);
|
||||
await Future.wait(children.map((child) => client.markRead(child)));
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.check),
|
||||
title: Text("Mark as Read"),
|
||||
),
|
||||
),
|
||||
if (room != null) ...[
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
final roomId = room!.metadata!.id;
|
||||
final accountData = ref.read(
|
||||
AccountDataController.provider.notifier,
|
||||
);
|
||||
final current = RoomNotificationLevel.of(
|
||||
ref.read(AccountDataController.provider).pushRules.global,
|
||||
roomId,
|
||||
);
|
||||
|
||||
final level = await context.showAdaptiveMenu(
|
||||
RoomNotificationLevel.values
|
||||
.map(
|
||||
(level) => PopupMenuItem(
|
||||
value: level,
|
||||
child: ListTile(
|
||||
selected: level == current,
|
||||
leading: Icon(level.icon),
|
||||
title: Text(level.title),
|
||||
subtitle: Text(level.description),
|
||||
trailing: level == current ? Icon(Icons.check) : null,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
if (level == null) return;
|
||||
await accountData
|
||||
.setRoomNotificationLevel(roomId, level)
|
||||
.onError(showError);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.notifications),
|
||||
title: Text("Notifications"),
|
||||
trailing: Icon(Icons.expand_more),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
final vias = ref.read(ViaController.provider(room!)).viaQuery;
|
||||
|
||||
await Clipboard.setData(
|
||||
.new(
|
||||
text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias",
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.link),
|
||||
title: Text("Copy Link"),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final userIdController = useTextEditingController();
|
||||
final reasonController = useTextEditingController();
|
||||
final text = useValueListenable(userIdController).text.trim();
|
||||
final valid = text.isValidUserId;
|
||||
|
||||
void submit() {
|
||||
if (!valid) return;
|
||||
Navigator.of(context).pop();
|
||||
final reason = reasonController.text.trim();
|
||||
client
|
||||
.setMembership(
|
||||
.new(
|
||||
userId: text,
|
||||
roomId: room!.metadata!.id,
|
||||
action: .invite,
|
||||
reason: reason.isEmpty ? null : reason,
|
||||
),
|
||||
)
|
||||
.onError(showError);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Invite User"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: valid ? submit : null,
|
||||
child: Text("Invite"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: userIdController,
|
||||
autofocus: true,
|
||||
keyboardType: .emailAddress,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: .new(
|
||||
labelText: "User ID",
|
||||
hintText: "@user:example.com",
|
||||
errorText: text.isEmpty || valid
|
||||
? null
|
||||
: "Enter a valid user ID",
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: reasonController,
|
||||
textCapitalization: .sentences,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: const .new(
|
||||
labelText: "Invite reason (optional)",
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.person_add),
|
||||
title: Text("Invite"),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => SettingsLayout(
|
||||
title: "Room Settings",
|
||||
sectionsProvider: RoomSettingsSectionsController.provider(
|
||||
room!.metadata!.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.settings),
|
||||
title: Text("Settings"),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => context.showLeaveRoomSheet(
|
||||
room!.metadata!.id,
|
||||
name: room!.metadata?.name ?? "Unnamed Room",
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.logout, color: danger),
|
||||
title: Text("Leave", style: .new(color: danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
209
lib/widgets/rooms/room_summary_sheet.dart
Normal file
209
lib/widgets/rooms/room_summary_sheet.dart
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/invites.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
import "package:nexus/helpers/extensions/room_summary_display.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/common/expandable_image.dart";
|
||||
import "package:nexus/widgets/rooms/knock_sheet.dart";
|
||||
import "package:nexus/widgets/common/linkified_text.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
|
||||
class const RoomSummarySheet(
|
||||
final RoomSummary summary, {
|
||||
final IList<String>? via,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
|
||||
final isInvite = summary.membership == .invite;
|
||||
final isKnocking = summary.membership == .knock;
|
||||
final mustKnock = summary.mustKnock(
|
||||
ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms.keys.toISet()),
|
||||
),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SheetLayout(
|
||||
title: Row(
|
||||
spacing: 12,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (summary.avatarUrl != null)
|
||||
ExpandableImage(
|
||||
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
|
||||
downloadFilename: name,
|
||||
child: AvatarOrHash(
|
||||
summary.avatarUrl,
|
||||
"",
|
||||
dimension: 64,
|
||||
fallback: Icon(summary.icon),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(summary.displayName, overflow: .ellipsis, maxLines: 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
if (isInvite) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
Navigator.of(context).pop();
|
||||
|
||||
InvitesController.respondMutation(summary.roomId)
|
||||
.run(
|
||||
container,
|
||||
(_) => container
|
||||
.read(InvitesController.provider.notifier)
|
||||
.decline(summary.roomId),
|
||||
)
|
||||
.onError(showError);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
child: Text("Decline"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
|
||||
InvitesController.respondMutation(summary.roomId)
|
||||
.run(container, (_) async {
|
||||
final roomId = await context.joinRoom(
|
||||
.new(roomIdOrAlias: summary.roomId, fromInvite: true),
|
||||
name: name,
|
||||
);
|
||||
if (roomId != null) {
|
||||
container
|
||||
.read(InvitesController.provider.notifier)
|
||||
.remove(summary.roomId);
|
||||
}
|
||||
})
|
||||
.onError(showError);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("Accept"),
|
||||
),
|
||||
] else if (isKnocking)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
Navigator.of(context).pop();
|
||||
|
||||
container
|
||||
.read(ClientController.provider.notifier)
|
||||
.leaveRoom(summary.roomId)
|
||||
.onError(showError);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
child: Text("Retract knock"),
|
||||
)
|
||||
else if (via != null && mustKnock)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await context.showAdaptiveSheet<bool>(
|
||||
(_) =>
|
||||
KnockSheet(summary.roomId, name: name, via: via!),
|
||||
) ==
|
||||
true &&
|
||||
context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Text("Knock"),
|
||||
)
|
||||
else if (via != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.joinRoom(
|
||||
.new(roomIdOrAlias: summary.roomId, via: via!),
|
||||
name: name,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("Join"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (summary.invitedBy case final invitedBy?)
|
||||
ListTile(
|
||||
leading: AvatarOrHash(
|
||||
summary.inviterProfile?.avatarUrl,
|
||||
summary.inviterProfile?.displayName ?? invitedBy.localpart,
|
||||
),
|
||||
title: Text(
|
||||
"${summary.isDirect ? "Direct message from" : "Invited by"} ${summary.inviterProfile?.displayName ?? invitedBy.localpart}",
|
||||
),
|
||||
subtitle: SelectableText(invitedBy),
|
||||
),
|
||||
if (summary.inviteReason case final reason?)
|
||||
ListTile(
|
||||
titleAlignment: ListTileTitleAlignment.titleHeight,
|
||||
leading: const Icon(Icons.format_quote),
|
||||
title: const Text("Invite reason"),
|
||||
subtitle: SelectableText(reason),
|
||||
),
|
||||
|
||||
if (summary.joinedMembers != null)
|
||||
ListTile(
|
||||
title: Text(
|
||||
"${summary.joinedMembers ?? "Unknown number of"} members",
|
||||
),
|
||||
leading: Icon(Icons.people),
|
||||
),
|
||||
|
||||
if (summary.canonicalAlias case final canonicalAlias?)
|
||||
ListTile(
|
||||
title: Text("Canonical Alias"),
|
||||
subtitle: SelectableText(canonicalAlias),
|
||||
leading: Icon(Icons.numbers),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text("Room ID"),
|
||||
subtitle: SelectableText(summary.roomId),
|
||||
leading: Icon(Icons.badge),
|
||||
),
|
||||
|
||||
if (summary.topic case final topic?)
|
||||
ListTile(
|
||||
title: Text("Topic"),
|
||||
subtitle: LinkifiedText(topic, roomId: summary.roomId),
|
||||
leading: Icon(Icons.info),
|
||||
),
|
||||
if (isKnocking)
|
||||
ListTile(
|
||||
title: Text("You've knocked"),
|
||||
subtitle: Text("Waiting for a moderator to let you in"),
|
||||
leading: Icon(Icons.hourglass_top),
|
||||
)
|
||||
else if (mustKnock)
|
||||
ListTile(
|
||||
title: Text("Knock to join"),
|
||||
leading: Icon(Icons.door_front_door),
|
||||
),
|
||||
if (summary.encryption != null)
|
||||
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
|
||||
if (summary.worldReadable == true)
|
||||
ListTile(
|
||||
title: Text("Anyone can read room history"),
|
||||
leading: Icon(Icons.public),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/widgets/rooms/room_title.dart
Normal file
22
lib/widgets/rooms/room_title.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// A room or space's name, with its topic underneath if it has one, for app
|
||||
/// bars.
|
||||
class const RoomTitle(final String name, {final String? topic, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(name, overflow: .ellipsis, maxLines: 1),
|
||||
if (topic?.isNotEmpty == true)
|
||||
Text(
|
||||
topic!,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
405
lib/widgets/rooms/space_hierarchy_sheet.dart
Normal file
405
lib/widgets/rooms/space_hierarchy_sheet.dart
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_hierarchy.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
import "package:nexus/helpers/extensions/room_summary_display.dart";
|
||||
import "package:nexus/helpers/extensions/show_leave_room_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/space_hierarchy.dart";
|
||||
import "package:nexus/widgets/rooms/add_space_rooms_sheet.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/common/expandable_image.dart";
|
||||
import "package:nexus/widgets/rooms/knock_sheet.dart";
|
||||
import "package:nexus/widgets/common/loading.dart";
|
||||
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
|
||||
|
||||
class const SpaceHierarchySheet(final String spaceId, {super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final joinedIds = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms.keys.toISet()),
|
||||
);
|
||||
|
||||
void showSummary(SpaceHierarchyRoom room, SpaceHierarchyChild child) =>
|
||||
context.showAdaptiveSheet(
|
||||
(_) => RoomSummarySheet(
|
||||
room.summary,
|
||||
via: joinedIds.contains(room.summary.roomId) ? null : child.via,
|
||||
),
|
||||
);
|
||||
|
||||
Widget membershipButton(
|
||||
SpaceHierarchyRoom room,
|
||||
SpaceHierarchyChild child, {
|
||||
required String name,
|
||||
}) => joinedIds.contains(room.summary.roomId)
|
||||
? TextButton(
|
||||
onPressed: () =>
|
||||
context.showLeaveRoomSheet(room.summary.roomId, name: name),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text("Leave"),
|
||||
)
|
||||
: room.summary.mustKnock(joinedIds)
|
||||
? TextButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) =>
|
||||
KnockSheet(room.summary.roomId, name: name, via: child.via),
|
||||
),
|
||||
child: const Text("Knock"),
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: () => context.joinRoom(
|
||||
.new(roomIdOrAlias: room.summary.roomId, via: child.via),
|
||||
name: name,
|
||||
),
|
||||
child: const Text("Join"),
|
||||
);
|
||||
|
||||
Widget removeButton(
|
||||
String spaceId,
|
||||
String roomId, {
|
||||
required String name,
|
||||
required String spaceName,
|
||||
}) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final mutation = SpaceHierarchyController.removeChildMutation((
|
||||
spaceId,
|
||||
roomId,
|
||||
));
|
||||
if (ref.watch(mutation).isPending) {
|
||||
return const Padding(
|
||||
padding: .all(12),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
tooltip: "Remove from space",
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(context) => SheetLayout(
|
||||
title: const Text("Remove Room"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
mutation.run(
|
||||
container,
|
||||
(_) => context.withProgressSnackbar(
|
||||
"Removing $name from $spaceName...",
|
||||
() async {
|
||||
await container
|
||||
.read(ClientController.provider.notifier)
|
||||
.setState(
|
||||
.new(
|
||||
roomId: spaceId,
|
||||
type: EventType.spaceChild.type,
|
||||
stateKey: roomId,
|
||||
content: const Content(),
|
||||
),
|
||||
);
|
||||
container.invalidate(
|
||||
SpaceHierarchyController.provider(spaceId),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text("Remove"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"Are you sure you want to remove \"$name\" from \"$spaceName\"? Its members will stay in it.",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget level(
|
||||
String spaceId,
|
||||
SpaceHierarchy hierarchy,
|
||||
ISet<String> ancestors,
|
||||
) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final loadingMore = useState(false);
|
||||
final adding = ref
|
||||
.watch(SpaceHierarchyController.addChildrenMutation(spaceId))
|
||||
.isPending;
|
||||
final canManage = ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.state(eventType: .spaceChild, roomId: spaceId),
|
||||
),
|
||||
);
|
||||
|
||||
final byId = IMap.fromValues(
|
||||
keyMapper: (room) => room.summary.roomId,
|
||||
values: hierarchy.rooms,
|
||||
);
|
||||
final spaceName = byId[spaceId]?.summary.displayName ?? "Unnamed Space";
|
||||
final children = (byId[spaceId]?.children ?? const .empty())
|
||||
.map(
|
||||
(child) => switch (byId[child.roomId]) {
|
||||
// Skip spaces that contain themselves somewhere up the tree.
|
||||
final room? when !ancestors.contains(child.roomId) => (
|
||||
child,
|
||||
room,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
// Suggested rooms first, then sorted by member count
|
||||
.sorted((a, b) {
|
||||
if (a.$1.suggested != b.$1.suggested) {
|
||||
return a.$1.suggested ? -1 : 1;
|
||||
}
|
||||
return (b.$2.summary.joinedMembers ?? 0).compareTo(
|
||||
a.$2.summary.joinedMembers ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
for (final (child, room) in children)
|
||||
if (room.summary.roomType != RoomType.space)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final summary = room.summary;
|
||||
final name = summary.displayName;
|
||||
|
||||
return ListTile(
|
||||
leading: AvatarOrHash(
|
||||
summary.avatarUrl,
|
||||
name,
|
||||
fallback: Icon(summary.icon),
|
||||
),
|
||||
title: Text(name, maxLines: 1, overflow: .ellipsis),
|
||||
subtitle: summary.topic == null
|
||||
? null
|
||||
: Text(
|
||||
summary.topic!,
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => showSummary(room, child),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
membershipButton(room, child, name: name),
|
||||
if (canManage)
|
||||
removeButton(
|
||||
spaceId,
|
||||
summary.roomId,
|
||||
name: name,
|
||||
spaceName: spaceName,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
for (final (child, room) in children)
|
||||
if (room.summary.roomType == RoomType.space)
|
||||
HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final expanded = useState(false);
|
||||
final summary = room.summary;
|
||||
final name = summary.displayName;
|
||||
final joined = joinedIds.contains(summary.roomId);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: joined
|
||||
? () => expanded.value = !expanded.value
|
||||
: () => showSummary(room, child),
|
||||
leading: AvatarOrHash(
|
||||
summary.avatarUrl,
|
||||
name,
|
||||
fallback: Icon(summary.icon),
|
||||
),
|
||||
title: Text(name, maxLines: 1, overflow: .ellipsis),
|
||||
subtitle: summary.topic == null
|
||||
? null
|
||||
: Text(
|
||||
summary.topic!,
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (joined)
|
||||
AnimatedRotation(
|
||||
turns: expanded.value ? 0.5 : 0,
|
||||
duration: Durations.medium2,
|
||||
child: const Icon(Icons.expand_more),
|
||||
),
|
||||
membershipButton(room, child, name: name),
|
||||
if (canManage)
|
||||
removeButton(
|
||||
spaceId,
|
||||
summary.roomId,
|
||||
name: name,
|
||||
spaceName: spaceName,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: joined && expanded.value
|
||||
? Padding(
|
||||
// Indented so it's clear which rooms are in which space
|
||||
padding: const .only(left: 16),
|
||||
child: ref
|
||||
.watch(
|
||||
SpaceHierarchyController.provider(
|
||||
summary.roomId,
|
||||
),
|
||||
)
|
||||
.betterWhen(
|
||||
skipLoadingOnRefresh: true,
|
||||
loading: () =>
|
||||
const Loading(dimension: 24),
|
||||
data: (hierarchy) => level(
|
||||
summary.roomId,
|
||||
hierarchy,
|
||||
ancestors.add(child.roomId),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (hierarchy.nextBatch != null || canManage)
|
||||
Row(
|
||||
mainAxisAlignment: .center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (hierarchy.nextBatch != null)
|
||||
TextButton(
|
||||
onPressed: loadingMore.value
|
||||
? null
|
||||
: () async {
|
||||
loadingMore.value = true;
|
||||
await ref
|
||||
.read(
|
||||
SpaceHierarchyController.provider(spaceId)
|
||||
.notifier,
|
||||
)
|
||||
.loadMore()
|
||||
.onError(showError);
|
||||
if (context.mounted) loadingMore.value = false;
|
||||
},
|
||||
child: const Text("Load more"),
|
||||
),
|
||||
if (canManage)
|
||||
TextButton.icon(
|
||||
onPressed: adding
|
||||
? null
|
||||
: () => context.showAdaptiveSheet(
|
||||
(_) => AddSpaceRoomsSheet(
|
||||
spaceId,
|
||||
spaceName: spaceName,
|
||||
existing:
|
||||
(byId[spaceId]?.children ?? const .empty())
|
||||
.map((child) => child.roomId)
|
||||
.toISet(),
|
||||
),
|
||||
),
|
||||
icon: adding
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.add),
|
||||
label: const Text("Add rooms"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return ref
|
||||
.watch(SpaceHierarchyController.provider(spaceId))
|
||||
.betterWhen(
|
||||
skipLoadingOnRefresh: true,
|
||||
data: (hierarchy) {
|
||||
final space = hierarchy.rooms
|
||||
.where((room) => room.summary.roomId == spaceId)
|
||||
.firstOrNull
|
||||
?.summary;
|
||||
|
||||
return SheetLayout(
|
||||
title: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (space?.avatarUrl case final avatarUrl?)
|
||||
ExpandableImage(
|
||||
.new(mxc: avatarUrl),
|
||||
downloadFilename:
|
||||
space?.name ?? space?.canonicalAlias ?? spaceId,
|
||||
child: AvatarOrHash(avatarUrl, "", dimension: 32),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
space?.displayName ?? "Unnamed Space",
|
||||
overflow: .ellipsis,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
level(spaceId, hierarchy, .new([spaceId])),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue