Add room preview dialog

Closes #79
This commit is contained in:
Henry Hiles 2026-09-29 13:55:17 -04:00
commit 281bab0202
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 230 additions and 204 deletions

2
.gitignore vendored
View file

@ -42,3 +42,5 @@ password.txt
# Nix
/result
# Widget Preview related
.widget_preview/

View file

@ -0,0 +1,36 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/experimental/mutation.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/space.dart";
extension FocusRoom on MutationTarget {
Future<void> focusRoom(String id) async {
final spaces = container.read(SpacesController.provider);
if (spaces.firstWhereOrNull((space) => space.id == id) case Space _?) {
return await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(id);
}
final parent = spaces.firstWhereOrNull(
(space) =>
space.children.any((room) => room.metadata?.id == id) ||
space.subSpaces.any(
(sub) =>
sub.room.metadata?.id == id ||
sub.children.any((room) => room.metadata?.id == id),
),
);
if (parent == null) return;
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(parent.id);
await container
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(id);
}
}

View file

@ -7,8 +7,9 @@ part "lazy_load_summary.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const LazyLoadSummary({
required final IList<String>? heroes,
required final int? joinedMemberCount,
@JsonKey(name: "m.heroes") required final IList<String>? heroes,
@JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount,
@JsonKey(name: "m.invited_member_count")
required final int? invitedMemberCount,
}) with _$LazyLoadSummary {
Map<String, Object?> toJson() => _$LazyLoadSummaryToJson(this);

View file

@ -9,9 +9,6 @@ part "room_metadata.g.dart";
@JsonSerializable()
class const RoomMetadata({
@JsonKey(name: "room_id") required final String id,
// CreateEventContent creationContent,
// TombstoneEventContent tombstoneEventContent,
final String? name,
final Uri? avatar,
final String? dmUserId,

View file

@ -23,9 +23,8 @@ final class const AvatarOrHash(
child: Center(
child: ClipRRect(
borderRadius: .all(.circular((height - 8) / 2.5)),
child: SizedBox(
width: height,
height: height,
child: SizedBox.square(
dimension: height,
child: avatar == null
? fallback ?? box
: Image(

View file

@ -1,16 +1,111 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
import "package:nexus/main.dart";
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
@override
Widget build(BuildContext context) {
final roomAlias = useTextEditingController();
Future<void> onJoin() async {
Navigator.of(context).pop();
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final IList<String> via = .new(
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
);
showDialog(
context: context,
builder: (context) => ref
.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: roomIdOrAlias, via: via),
),
)
.betterWhen(
data: (summary) => RoomSummaryDialog(
summary,
extraActions: .new([
TextButton(
onPressed: () async {
// Capture everything before the dialog closes.
final container = ProviderScope.containerOf(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final colors = Theme.of(context).colorScheme;
Navigator.of(context).pop();
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999),
),
);
try {
final id = await container
.read(ClientController.provider.notifier)
.joinRoom(
.new(roomIdOrAlias: roomIdOrAlias, via: via),
);
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text(
"Room $roomIdOrAlias successfully joined.",
),
action: .new(
label: "Open",
onPressed: () =>
container.focusRoom(id).onError(showError),
),
),
);
} catch (error) {
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: colors.errorContainer,
content: Text(
error.toString(),
style: .new(color: colors.onErrorContainer),
),
),
);
}
},
child: Text("Join"),
),
]),
),
loading: () => Dialog(
child: Row(
mainAxisAlignment: .center,
mainAxisSize: .min,
children: [
Padding(
padding: .all(24),
child: CircularProgressIndicator(),
),
],
),
),
),
);
}
return AlertDialog(
title: Text("Join a Room"),
content: Column(
@ -21,111 +116,14 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
SizedBox(height: 12),
TextField(
controller: roomAlias,
decoration: .new(hintText: "#room:server"),
decoration: .new(hintText: "#room:server.name"),
onEditingComplete: onJoin,
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
if (context.mounted) {
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999),
),
);
try {
final id = await ref
.watch(ClientController.provider.notifier)
.joinRoom(
.new(
roomIdOrAlias: roomIdOrAlias,
via: .new(
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
),
),
);
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text("Room $roomIdOrAlias successfully joined."),
action: .new(
label: "Open",
onPressed: () async {
final spaces = ref.watch(SpacesController.provider);
final space = spaces.firstWhereOrNull(
(space) => space.id == id,
);
await ref
.watch(
KeyController.provider(KeyController.spaceKey)
.notifier,
)
.set(
space?.id ??
spaces
.firstWhere(
(space) =>
space.children.any(
(child) =>
child.metadata?.id == id,
) ||
space.subSpaces.any(
(child) =>
child.room.metadata?.id == id,
),
)
.id,
);
if (space == null) {
await ref
.watch(
KeyController.provider(KeyController.roomKey)
.notifier,
)
.set(id);
}
},
),
),
);
} catch (error) {
snackbar.close();
if (context.mounted) {
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: Theme.of(context)
.colorScheme
.errorContainer,
content: Text(
error.toString(),
style: .new(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
}
}
},
child: Text("Join"),
),
TextButton(onPressed: onJoin, child: Text("Join")),
],
);
}

View file

@ -1,14 +1,12 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/contextual_event.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notifications.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
@ -131,41 +129,7 @@ class const NotificationsPage({
child: HighlightWrapper(
InkWell(
onTap: () async {
final spaces = ref.read(
SpacesController.provider,
);
final space = spaces.firstWhereOrNull(
(space) =>
space.children.any(
(room) =>
room.metadata?.id ==
event.roomId,
) ||
space.subSpaces.any(
(subSpace) =>
subSpace.children.any(
(room) =>
room.metadata?.id ==
event.roomId,
),
),
);
if (space == null) return;
await ref
.read(
KeyController.provider(
KeyController.spaceKey,
).notifier,
)
.set(space.id);
await ref
.read(
KeyController.provider(
KeyController.roomKey,
).notifier,
)
.set(event.roomId);
await ref.focusRoom(event.roomId);
ref
.watch(
ContextualEventController.provider(

View file

@ -3,9 +3,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/widgets/room_menu.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
final class const RoomAppbar({
required final String? roomId,
@ -25,61 +24,19 @@ final class const RoomAppbar({
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
return Appbar(
onTap: room == null
onTap: room?.metadata == null
? null
: () => showDialog(
context: context,
builder: (context) => Dialog(
constraints: .loose(.fromWidth(400)),
child: Padding(
padding: .all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
spacing: 8,
children: [
Row(
spacing: 12,
mainAxisSize: .min,
children: [
if (room.metadata?.avatar != null)
ExpandableImage(
room.metadata?.avatar == null
? null
: .new(mxc: room.metadata!.avatar!),
child: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
height: 64,
fallback: Icon(Icons.numbers),
),
),
Expanded(
child: Text(
room.metadata?.name ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
style: Theme.of(context)
.textTheme
.headlineSmall,
),
),
],
),
if (room.metadata?.topic?.isNotEmpty == true)
LinkifiedText(
room.metadata!.topic!,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
builder: (context) => RoomSummaryDialog(
.new(
roomId: room!.metadata!.id,
joinedMembers:
room.metadata!.lazyLoadSummary?.joinedMemberCount ?? -1,
avatarUrl: room.metadata?.avatar,
canonicalAlias: room.metadata?.canonicalAlias,
name: room.metadata?.name,
topic: room.metadata?.topic,
),
),
),

View file

@ -0,0 +1,72 @@
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:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/models/room_summary.dart";
class const RoomSummaryDialog(
final RoomSummary summary, {
final IList<Widget> extraActions = const IList.empty(),
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => AlertDialog(
constraints: .loose(.fromWidth(400)),
scrollable: true,
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
title: Row(
spacing: 12,
mainAxisSize: .min,
children: [
if (summary.avatarUrl != null)
ExpandableImage(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
child: AvatarOrHash(
summary.avatarUrl,
summary.name ?? "Unnamed Room",
height: 64,
fallback: Icon(Icons.numbers),
),
),
Expanded(
child: Text(
summary.name ?? summary.canonicalAlias ?? summary.roomId,
overflow: .ellipsis,
maxLines: 3,
),
),
],
),
content: Column(
children: [
ListTile(
title: Text("${summary.joinedMembers} members"),
leading: Icon(Icons.people),
),
ListTile(
title: SelectableText(summary.canonicalAlias ?? summary.roomId),
leading: Icon(Icons.numbers),
),
if (summary.topic != null)
ListTile(
isThreeLine: true,
subtitle: LinkifiedText(
summary.topic!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
leading: Icon(Icons.info),
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
...extraActions,
],
);
}