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] Users
- [x] Clickable - [x] Clickable
- [x] Rooms - [x] Rooms
- [ ] Clickable - [x] Clickable
- [x] Matrix URIs - [x] Matrix URIs
- [x] Matrix.to links - [x] Matrix.to links
- [x] Events - [ ] Events
- [ ] Render more nicely - [ ] Render more nicely
- [ ] Clickable - [ ] Clickable
- [x] Custom emojis/stickers - [x] Custom emojis/stickers

View file

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

View file

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

View file

@ -9,7 +9,7 @@ part "room_summary.g.dart";
@JsonSerializable() @JsonSerializable()
class const RoomSummary({ class const RoomSummary({
required final String roomId, 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 JoinRule? joinRule,
final String? name, final String? name,
final Uri? avatarUrl, 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:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/room_summary.dart"; import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.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/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/room_summary.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}) class const MentionChip(final String content, final String? roomId, {super.key})
extends ConsumerWidget { extends ConsumerWidget {
@ -25,7 +27,8 @@ class const MentionChip(final String content, final String? roomId, {super.key})
.watch( .watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)), RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
) )
.whenOrNull(data: (data) => data), .whenOrNull(data: (data) => data) ??
RoomSummary(roomId: mention),
_ => null, _ => null,
}; };
@ -33,11 +36,28 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null return mention == null
? SizedBox.shrink() ? SizedBox.shrink()
: InkWell( : InkWell(
onTap: () { onTap: () async {
if (data case MembershipContent membership) { if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId); context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) { } 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( 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:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_summary.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/helpers/extensions/link_to_mention.dart";
import "package:nexus/widgets/room_summary_dialog.dart"; import "package:nexus/widgets/room_summary_dialog.dart";
import "package:nexus/main.dart";
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget { class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
@override @override
@ -19,90 +14,21 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final IList<String> via = .new( final via = roomAlias.text.via;
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
);
showDialog( showDialog(
context: context, context: context,
builder: (context) => ref builder: (context) => switch (ref.watch(
.watch(
RoomSummaryController.provider( RoomSummaryController.provider(
.new(roomIdOrAlias: roomIdOrAlias, via: via), .new(roomIdOrAlias: roomIdOrAlias, via: via),
), ),
) )) {
.betterWhen( AsyncData(:final value) => RoomSummaryDialog(value, via: via),
data: (summary) => RoomSummaryDialog( AsyncError _ || AsyncLoading _ => RoomSummaryDialog(
summary, .new(roomId: roomAlias.text),
extraActions: .new([ via: via,
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(),
),
],
),
),
),
); );
} }

View file

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

View file

@ -1,6 +1,8 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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: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/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart"; import "package:nexus/widgets/linkified_text.dart";
@ -8,11 +10,11 @@ import "package:nexus/models/room_summary.dart";
class const RoomSummaryDialog( class const RoomSummaryDialog(
final RoomSummary summary, { final RoomSummary summary, {
final IList<Widget> extraActions = const IList.empty(), final IList<String>? via,
super.key, super.key,
}) extends ConsumerWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => AlertDialog( Widget build(BuildContext context) => AlertDialog(
constraints: .loose(.fromWidth(400)), constraints: .loose(.fromWidth(400)),
scrollable: true, scrollable: true,
contentPadding: EdgeInsets.all(24).copyWith(top: 8), contentPadding: EdgeInsets.all(24).copyWith(top: 8),
@ -25,14 +27,14 @@ class const RoomSummaryDialog(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
child: AvatarOrHash( child: AvatarOrHash(
summary.avatarUrl, summary.avatarUrl,
summary.name ?? "Unnamed Room", "",
height: 64, height: 64,
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
), ),
), ),
Expanded( Expanded(
child: Text( child: Text(
summary.name ?? summary.canonicalAlias ?? summary.roomId, summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
overflow: .ellipsis, overflow: .ellipsis,
maxLines: 3, maxLines: 3,
), ),
@ -42,7 +44,9 @@ class const RoomSummaryDialog(
content: Column( content: Column(
children: [ children: [
ListTile( ListTile(
title: Text("${summary.joinedMembers} members"), title: Text(
"${summary.joinedMembers ?? "Unknown number of"} members",
),
leading: Icon(Icons.people), leading: Icon(Icons.people),
), ),
ListTile( ListTile(
@ -66,7 +70,55 @@ class const RoomSummaryDialog(
actions: [ actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), 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"),
),
], ],
); );
} }