Clickable room links

Fixes #80
This commit is contained in:
Henry Hiles 2026-09-29 16:02:25 -04:00
commit c8b9654219
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 113 additions and 107 deletions

View file

@ -81,10 +81,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Users
- [x] Clickable
- [x] Rooms
- [ ] Clickable
- [x] Clickable
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- [ ] Events
- [ ] Render more nicely
- [ ] Clickable
- [x] Custom emojis/stickers

View file

@ -5,13 +5,14 @@ import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/space.dart";
extension FocusRoom on MutationTarget {
Future<void> focusRoom(String id) async {
Future<bool> focusRoom(String id) async {
final spaces = container.read(SpacesController.provider);
if (spaces.firstWhereOrNull((space) => space.id == id) case Space _?) {
return await container
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(id);
return true;
}
final parent = spaces.firstWhereOrNull(
@ -23,7 +24,7 @@ extension FocusRoom on MutationTarget {
sub.children.any((room) => room.metadata?.id == id),
),
);
if (parent == null) return;
if (parent == null) return false;
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
@ -32,5 +33,7 @@ extension FocusRoom on MutationTarget {
await container
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(id);
return true;
}
}

View file

@ -1,3 +1,5 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
extension LinkToMention on String {
/// Extracts a Matrix identifier from this string.
///
@ -43,4 +45,7 @@ extension LinkToMention on String {
return null;
}
IList<String> get via =>
.new(Uri.tryParse(replaceAll("/#", ""))?.queryParametersAll["via"] ?? []);
}

View file

@ -9,7 +9,7 @@ part "room_summary.g.dart";
@JsonSerializable()
class const RoomSummary({
required final String roomId,
@JsonKey(name: "num_joined_members") required final int joinedMembers,
@JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule,
final String? name,
final Uri? avatarUrl,

View file

@ -2,10 +2,12 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
class const MentionChip(final String content, final String? roomId, {super.key})
extends ConsumerWidget {
@ -22,10 +24,11 @@ class const MentionChip(final String content, final String? roomId, {super.key})
"#" || "!" =>
ref
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data),
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data) ??
RoomSummary(roomId: mention),
_ => null,
};
@ -33,11 +36,28 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null
? SizedBox.shrink()
: InkWell(
onTap: () {
onTap: () async {
if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) {
// TODO: Handle summary
if (!await ref.focusRoom(summary.roomId) && context.mounted) {
showDialog(
context: context,
builder: (context) => Consumer(
builder: (context, ref, _) => RoomSummaryDialog(
ref
.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: mention),
),
)
.whenOrNull(data: (data) => data) ??
summary,
via: content.via,
),
),
);
}
}
},
child: Text(

View file

@ -1,14 +1,9 @@
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/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
@ -19,90 +14,21 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
Navigator.of(context).pop();
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final IList<String> via = .new(
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
);
final via = roomAlias.text.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(),
),
],
),
),
),
builder: (context) => switch (ref.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: roomIdOrAlias, via: via),
),
)) {
AsyncData(:final value) => RoomSummaryDialog(value, via: via),
AsyncError _ || AsyncLoading _ => RoomSummaryDialog(
.new(roomId: roomAlias.text),
via: via,
),
},
);
}

View file

@ -32,7 +32,7 @@ final class const RoomAppbar({
.new(
roomId: room!.metadata!.id,
joinedMembers:
room.metadata!.lazyLoadSummary?.joinedMemberCount ?? -1,
room.metadata!.lazyLoadSummary?.joinedMemberCount,
avatarUrl: room.metadata?.avatar,
canonicalAlias: room.metadata?.canonicalAlias,
name: room.metadata?.name,

View file

@ -1,6 +1,8 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
@ -8,11 +10,11 @@ import "package:nexus/models/room_summary.dart";
class const RoomSummaryDialog(
final RoomSummary summary, {
final IList<Widget> extraActions = const IList.empty(),
final IList<String>? via,
super.key,
}) extends ConsumerWidget {
}) extends StatelessWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => AlertDialog(
Widget build(BuildContext context) => AlertDialog(
constraints: .loose(.fromWidth(400)),
scrollable: true,
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
@ -25,14 +27,14 @@ class const RoomSummaryDialog(
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,
summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
),
@ -42,7 +44,9 @@ class const RoomSummaryDialog(
content: Column(
children: [
ListTile(
title: Text("${summary.joinedMembers} members"),
title: Text(
"${summary.joinedMembers ?? "Unknown number of"} members",
),
leading: Icon(Icons.people),
),
ListTile(
@ -66,7 +70,55 @@ class const RoomSummaryDialog(
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
...extraActions,
if (via != null)
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 roomIdOrAlias = summary.canonicalAlias ?? summary.roomId;
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: summary.roomId, via: via!));
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text("Room $roomIdOrAlias successfully joined."),
action: .new(
label: "Open",
onPressed: () => container.focusRoom(id),
),
),
);
} catch (error) {
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: colors.errorContainer,
content: Text(
error.toString(),
style: .new(color: colors.onErrorContainer),
),
),
);
}
},
child: Text("Join"),
),
],
);
}