reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
10 changed files with 568 additions and 547 deletions

View 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,
),
),
],
);
}
}

View 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,
),
],
);
}
}

View 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,
),
),
],
),
],
);
}
}

View 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,
),
],
),
],
);
}
}

View 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,
),
],
);
}
}

View 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"),
),
),
],
),
),
),
);
}
}

View 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)),
),
),
],
],
);
}
}

View 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),
),
],
),
],
);
}
}

View 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),
),
],
);
}

View 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])),
],
);
},
);
}
}