diff --git a/README.md b/README.md index 4948ecc..babe4f4 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Clickable - [x] Matrix URIs - [x] Matrix.to links - - [ ] Events - - [ ] Render more nicely - - [ ] Clickable + - [x] Events - [x] Custom emojis/stickers - [x] History loading - [x] Editing diff --git a/lib/controllers/contextual_event.dart b/lib/controllers/contextual_event.dart index 42c5a26..e17c0a4 100644 --- a/lib/controllers/contextual_event.dart +++ b/lib/controllers/contextual_event.dart @@ -9,8 +9,8 @@ class ContextualEventController(String? _) extends Notifier { @override bool updateShouldNotify(_, _) => true; - static final provider = NotifierProvider.family - .autoDispose( + static final provider = + NotifierProvider.family( ContextualEventController.new, ); } diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index 7a1e5dc..5c6513b 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -16,11 +16,28 @@ class EventController(final GetEventRequest request) (event) => event.eventId == request.eventId, ); - return event ?? - await ref - .watch(ClientController.provider.notifier) - .getEvent(request) - .onError((_, _) => null); + if (event != null) return event; + + final fetchedEvent = await ref + .watch(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null); + + if (fetchedEvent != null) { + ref + .watch(RoomsController.provider.notifier) + .update( + .new({ + request.roomId: .new( + events: .new({fetchedEvent.rowId: fetchedEvent}), + ), + }), + ); + + return fetchedEvent; + } + + return null; } static final provider = AsyncNotifierProvider.family diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index dd75cf3..489af72 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -59,7 +59,6 @@ class GomuksListenerController extends AsyncNotifier { .new({ event.roomId: .new(events: .new({event.rowId: event})), }), - .new(), ); break; diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index 33e1cb3..43d9b7a 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -35,7 +35,6 @@ class NotificationsController([final NotificationsRequest? request]) ), ), }), - .new(), ); return .new(mentions.map((event) => (event.rowId, event.roomId))); @@ -79,7 +78,6 @@ class NotificationsController([final NotificationsRequest? request]) ), ), }), - .new(), ); return currentNotifications.addAll( diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index a2d9590..7150206 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -77,7 +77,6 @@ class RoomChatController(final ChatParams info) ), ), }), - .new(), ); return .new( timeline: .new(events.map((element) => element.rowId)), @@ -149,7 +148,6 @@ class RoomChatController(final ChatParams info) ), ), }), - .new(), ); } else { final paginationResponse = await client.paginateManual( @@ -173,7 +171,6 @@ class RoomChatController(final ChatParams info) ), ), }), - .new(), ); final eventRowIds = paginationResponse.events @@ -274,7 +271,6 @@ class RoomChatController(final ChatParams info) clientSticky: .new({event.rowId}), ), }), - .new(), ); } diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 2e8c2a9..034f2f8 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -33,7 +33,10 @@ class RoomsController extends Notifier> { .new(), ); - void update(IMap rooms, ISet leftRooms) { + void update( + IMap rooms, [ + ISet leftRooms = const ISet.empty(), + ]) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index d8a8e83..c8d313e 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -202,7 +202,7 @@ extension BuildEventOptions on Event { await Clipboard.setData( ClipboardData( text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/$eventId$vias", + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${eventId.substring(1)}$vias", ), ); }, diff --git a/lib/helpers/extensions/focus_room.dart b/lib/helpers/extensions/focus_room.dart index c4c86c2..64ddb7f 100644 --- a/lib/helpers/extensions/focus_room.dart +++ b/lib/helpers/extensions/focus_room.dart @@ -1,27 +1,28 @@ import "package:collection/collection.dart"; import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:nexus/controllers/contextual_event.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/spaces.dart"; import "package:nexus/models/space.dart"; extension FocusRoom on MutationTarget { - Future focusRoom(String id) async { + Future focusRoom(String roomId, [int? eventRowId]) async { final spaces = container.read(SpacesController.provider); - if (spaces.firstWhereOrNull((space) => space.id == id) case Space _?) { + if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) { await container .read(KeyController.provider(KeyController.spaceKey).notifier) - .set(id); + .set(roomId); return true; } final parent = spaces.firstWhereOrNull( (space) => - space.children.any((room) => room.metadata?.id == id) || + space.children.any((room) => room.metadata?.id == roomId) || space.subSpaces.any( (sub) => - sub.room.metadata?.id == id || - sub.children.any((room) => room.metadata?.id == id), + sub.room.metadata?.id == roomId || + sub.children.any((room) => room.metadata?.id == roomId), ), ); if (parent == null) return false; @@ -32,7 +33,13 @@ extension FocusRoom on MutationTarget { await container .read(KeyController.provider(KeyController.roomKey).notifier) - .set(id); + .set(roomId); + + if (eventRowId != null) { + container + .read(ContextualEventController.provider(roomId).notifier) + .set(eventRowId); + } return true; } diff --git a/lib/helpers/extensions/get_link_info.dart b/lib/helpers/extensions/get_link_info.dart new file mode 100644 index 0000000..28e999b --- /dev/null +++ b/lib/helpers/extensions/get_link_info.dart @@ -0,0 +1,70 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +/// A parsed Matrix link. +/// +/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"), +/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any +/// `via` servers from the link. +typedef LinkInfo = ({String identifier, String? eventId, IList via}); + +extension GetLinkInfo on String { + /// Parses this string as a Matrix link. + /// + /// Supports: + /// - https://matrix.to/#/{id}[/{eventId}][?via=...] + /// - matrix:roomid/{id}[/e/{eventId}][?via=...] + /// - matrix:r/{alias}[/e/{eventId}][?via=...] + /// - matrix:u/{userId} + /// + /// Returns null if this is not a Matrix link. + LinkInfo? get linkInfo { + final uri = Uri.tryParse(trim()); + if (uri == null) return null; + + if ((uri.scheme == "http" || uri.scheme == "https") && + uri.host == "matrix.to") { + // matrix.to keeps everything, including the query, in the fragment, + // so parse the fragment as its own URI. + final inner = Uri.tryParse(uri.fragment); + final segments = inner?.pathSegments.where((s) => s.isNotEmpty).toList(); + if (inner == null || segments == null || segments.isEmpty) return null; + + return ( + identifier: segments.first, + eventId: segments.length > 1 && segments[1].startsWith(r"$") + ? segments[1] + : null, + via: .new(inner.queryParametersAll["via"] ?? []), + ); + } + + if (uri.scheme == "matrix") { + final segments = uri.pathSegments; + if (segments.length < 2 || segments[1].isEmpty) return null; + + final sigil = switch (segments[0].toLowerCase()) { + "r" => "#", + "roomid" => "!", + "u" => "@", + _ => null, + }; + if (sigil == null) return null; + + // Event segment is "e/{eventId}" (without the "$" sigil) and is + // only valid on room links. + final hasEvent = + sigil != "@" && + segments.length >= 4 && + segments[2].toLowerCase() == "e" && + segments[3].isNotEmpty; + + return ( + identifier: "$sigil${segments[1]}", + eventId: hasEvent ? "\$${segments[3]}" : null, + via: .new(uri.queryParametersAll["via"] ?? []), + ); + } + + return null; + } +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart deleted file mode 100644 index 41c9bd6..0000000 --- a/lib/helpers/extensions/link_to_mention.dart +++ /dev/null @@ -1,51 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; - -extension LinkToMention on String { - /// Extracts a Matrix identifier from this string. - /// - /// Supports: - /// - https://matrix.to/#/... - /// - matrix:roomid/... - /// - matrix:r/... - /// - matrix:u/... - /// - /// Returns the decoded identifier (e.g. "#room:matrix.org") - /// or null if this is not a Matrix link. - /// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code. - String? get mention { - final trimmed = trim(); - - final matrixTo = RegExp( - r"^https?://matrix\.to/#/(.[^/?#]+)", - caseSensitive: false, - ); - - final matrixToMatch = matrixTo.firstMatch(trimmed); - if (matrixToMatch != null) { - return Uri.decodeComponent(matrixToMatch.group(1)!); - } - - if (trimmed.toLowerCase().startsWith("matrix:")) { - try { - final uri = Uri.parse(trimmed); - - if (uri.pathSegments.isNotEmpty) { - final identifier = uri.pathSegments.last; - if (identifier.isNotEmpty) { - return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", - "u" => "@", - _ => "", - }}${Uri.decodeComponent(identifier)}"; - } - } - } catch (_) {} - } - - return null; - } - - IList get via => - .new(Uri.tryParse(replaceAll("/#", ""))?.queryParametersAll["via"] ?? []); -} diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 60caac0..4393c61 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/widgets/expandable_image.dart"; @@ -70,7 +70,7 @@ class const Html( ), "a" => - element.attributes["href"]?.mention == null + element.attributes["href"]?.linkInfo == null ? null : InlineCustomWidget( child: MentionChip(element.attributes["href"]!, roomId), diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index e36d2e5..29e2310 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -1,9 +1,10 @@ import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event.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/get_link_info.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/room_summary.dart"; @@ -13,7 +14,8 @@ class const MentionChip(final String content, final String? roomId, {super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mention = content.mention; + final linkInfo = content.linkInfo; + final mention = linkInfo?.identifier; final data = switch (mention?.characters.firstOrNull) { "@" => ref @@ -40,7 +42,22 @@ class const MentionChip(final String content, final String? roomId, {super.key}) if (data case MembershipContent membership) { context.showUserPopover(membership, mention, roomId: roomId); } else if (data case RoomSummary summary) { - if (!await ref.focusRoom(summary.roomId) && context.mounted) { + // TODO: This is an extra call that isn't truly needed, get rid of it + final eventRowId = linkInfo?.eventId == null + ? null + : await ref + .read( + EventController.provider( + .new( + roomId: summary.roomId, + eventId: linkInfo!.eventId!, + ), + ).selectAsync((data) => data?.rowId), + ) + .onError((_, _) => null); + + if (!await ref.focusRoom(summary.roomId, eventRowId) && + context.mounted) { showDialog( context: context, builder: (context) => Consumer( @@ -53,7 +70,7 @@ class const MentionChip(final String content, final String? roomId, {super.key}) ) .whenOrNull(data: (data) => data) ?? summary, - via: content.via, + via: linkInfo?.via, ), ), ); @@ -63,9 +80,7 @@ class const MentionChip(final String content, final String? roomId, {super.key}) child: Text( switch (data) { RoomSummary summary => - (summary.name == null ? null : "#${summary.name}") ?? - summary.canonicalAlias ?? - summary.roomId, + "${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}", MembershipContent membership => membership.displayName == null ? mention diff --git a/lib/widgets/join_dialog.dart b/lib/widgets/join_dialog.dart index f6c63e0..d46c9f6 100644 --- a/lib/widgets/join_dialog.dart +++ b/lib/widgets/join_dialog.dart @@ -1,8 +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/room_summary.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/widgets/room_summary_dialog.dart"; class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget { @@ -13,20 +14,26 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget { Future onJoin() async { Navigator.of(context).pop(); - final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; - final via = roomAlias.text.via; + final linkInfo = roomAlias.text.linkInfo; + final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text; showDialog( context: context, builder: (context) => switch (ref.watch( RoomSummaryController.provider( - .new(roomIdOrAlias: roomIdOrAlias, via: via), + .new( + roomIdOrAlias: roomIdOrAlias, + via: linkInfo?.via ?? const IList.empty(), + ), ), )) { - AsyncData(:final value) => RoomSummaryDialog(value, via: via), + AsyncData(:final value) => RoomSummaryDialog( + value, + via: linkInfo?.via, + ), AsyncError _ || AsyncLoading _ => RoomSummaryDialog( .new(roomId: roomAlias.text), - via: via, + via: linkInfo?.via, ), }, ); diff --git a/lib/widgets/pages/notifications.dart b/lib/widgets/pages/notifications.dart index 3347e09..94b7c8e 100644 --- a/lib/widgets/pages/notifications.dart +++ b/lib/widgets/pages/notifications.dart @@ -4,7 +4,6 @@ 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/notifications.dart"; import "package:nexus/helpers/extensions/focus_room.dart"; import "package:nexus/models/event.dart"; @@ -129,18 +128,8 @@ class const NotificationsPage({ child: HighlightWrapper( InkWell( onTap: () async { - await ref.focusRoom(roomId); - ref - .watch( - ContextualEventController.provider( - roomId, - ).notifier, - ) - .set(rowId); - - if (context.mounted) { - Navigator.of(context).pop(); - } + Navigator.of(context).pop(); + await ref.focusRoom(roomId, rowId); }, child: IgnorePointer( child: EventRenderer( diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 122f3cc..2473708 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -13,6 +13,8 @@ import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; @@ -168,14 +170,20 @@ final class const RoomChat({ Positioned.fill( child: Padding( padding: .symmetric(horizontal: 4), - child: ChatTimeline( - scroll: scroll, - roomId: roomId, - jumpToEvent: jumpToEvent, - getEventOptions: getEventOptions, - highlightedEvent: highlightedEvent.value, - composerHeight: composerSize.value, - ), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + roomId: roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, ), ), Positioned(