From 62f8e675a4339ceaac2528f3c84e1e723d371ed6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 19 Mar 2026 11:26:00 -0400 Subject: [PATCH] load replies at render time --- lib/controllers/event_controller.dart | 20 +++ lib/controllers/message_controller.dart | 26 +--- lib/models/message_config.dart | 10 ++ lib/models/requests/get_event_request.dart | 9 ++ lib/widgets/chat_page/reply_widget.dart | 146 ++++++++++++++++++ lib/widgets/chat_page/room_chat.dart | 7 +- .../chat_page/text_message_wrapper.dart | 8 +- lib/widgets/chat_page/top_widget.dart | 91 ----------- lib/widgets/loading.dart | 15 +- 9 files changed, 208 insertions(+), 124 deletions(-) create mode 100644 lib/controllers/event_controller.dart create mode 100644 lib/widgets/chat_page/reply_widget.dart delete mode 100644 lib/widgets/chat_page/top_widget.dart diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart new file mode 100644 index 0000000..4f72963 --- /dev/null +++ b/lib/controllers/event_controller.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/get_event_request.dart"; + +class EventController extends AsyncNotifier { + final GetEventRequest request; + EventController(this.request); + + @override + Future build() async { + final client = ref.watch(ClientController.provider.notifier); + return await client.getEvent(request).onError((_, _) => null); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + EventController.new, + ); +} diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index 74c9473..f3ef13b 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,12 +1,10 @@ import "package:collection/collection.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/members_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/models/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; class MessageController extends AsyncNotifier { final MessageConfig config; @@ -18,7 +16,6 @@ class MessageController extends AsyncNotifier { if (config.event.relationType == "m.replace" && !config.includeEdits) { return null; } - final client = ref.watch(ClientController.provider.notifier); if (!ref.mounted) return null; final event = config.event.lastEditRowId == null @@ -28,17 +25,9 @@ class MessageController extends AsyncNotifier { ) ?? config.event; - final replyId = - config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; - final replyEvent = replyId == null - ? null - : await client - .getEvent(GetEventRequest(room: config.room, eventId: replyId)) - .onError((_, _) => null); - if (!ref.mounted) return null; - final members = ref.watch(MembersController.provider(config.room)); + final members = ref.read(MembersController.provider(config.room)); final author = members.firstWhereOrNull( (member) => member.stateKey == event.authorId, ); @@ -59,16 +48,6 @@ class MessageController extends AsyncNotifier { "body": config.event.redactedBy == null ? (newContent?["body"] ?? content["body"] ?? "") : "Deleted Message", - if (replyEvent != null) - "reply": await ref.watch( - MessageController.provider( - MessageConfig( - event: replyEvent, - room: config.room, - alwaysReturn: true, - ), - ).future, - ), "flashing": false, "timelineId": event.timelineRowId, "big": event.localContent?.bigEmoji == true, @@ -102,6 +81,9 @@ class MessageController extends AsyncNotifier { // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", // ); + final replyId = + config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; + final asText = Message.text( metadata: metadata, diff --git a/lib/models/message_config.dart b/lib/models/message_config.dart index f7490e5..9020f78 100644 --- a/lib/models/message_config.dart +++ b/lib/models/message_config.dart @@ -6,6 +6,7 @@ part "message_config.g.dart"; @freezed abstract class MessageConfig with _$MessageConfig { + const MessageConfig._(); const factory MessageConfig({ @Default(false) bool alwaysReturn, @Default(false) bool includeEdits, @@ -13,6 +14,15 @@ abstract class MessageConfig with _$MessageConfig { required Event event, }) = _MessageConfig; + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is MessageConfig && + other.event.eventId == event.eventId; + + @override + int get hashCode => Object.hash(runtimeType, event.eventId); + factory MessageConfig.fromJson(Map json) => _$MessageConfigFromJson(json); } diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart index 44f5062..9374f3a 100644 --- a/lib/models/requests/get_event_request.dart +++ b/lib/models/requests/get_event_request.dart @@ -18,6 +18,15 @@ abstract class GetEventRequest with _$GetEventRequest { "unredact": unredact, }; + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is GetEventRequest && + other.eventId == eventId; + + @override + int get hashCode => Object.hash(runtimeType, eventId); + factory GetEventRequest.fromJson(Map json) => _$GetEventRequestFromJson(json); } diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart new file mode 100644 index 0000000..cd30acc --- /dev/null +++ b/lib/widgets/chat_page/reply_widget.dart @@ -0,0 +1,146 @@ +import "dart:math"; +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event_controller.dart"; +import "package:nexus/controllers/message_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/message_config.dart"; +import "package:nexus/models/requests/get_event_request.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/chat_page/html/quoted.dart"; + +typedef OnTapReply = void Function(Message message)?; + +class ReplyWidget extends ConsumerWidget { + final Message message; + final bool alwaysShow; + final Room room; + final MessageGroupStatus? groupStatus; + final OnTapReply onTapReply; + const ReplyWidget( + this.message, { + required this.room, + required this.groupStatus, + this.onTapReply, + this.alwaysShow = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => + message.replyToMessageId == null + ? SizedBox.shrink() + : Padding( + padding: EdgeInsets.only(bottom: 12), + child: Quoted( + ref + .watch( + EventController.provider( + GetEventRequest( + room: room, + eventId: message.replyToMessageId!, + ), + ), + ) + .betterWhen( + loading: () => Text("Fetching event..."), + data: (event) => event == null + ? SizedBox.shrink() + : ref + .watch( + MessageController.provider( + MessageConfig(room: room, event: event), + ), + ) + .betterWhen( + loading: () => Text("Parsing message..."), + data: (replyMessage) { + if (replyMessage == null) { + return SizedBox.shrink(); + } + + final smallerText = + message is TextMessage && + replyMessage.metadata?["body"] != null + ? replyMessage.metadata!["body"].substring( + 0, + min( + max( + max( + (message as TextMessage) + .text + .length - + (replyMessage + .metadata?["displayName"] + as String) + .length - + 5, + message + .metadata?["displayName"] + .length, + ), + 5, + ), + replyMessage.metadata!["body"].length, + ), + ) + : null; + final replyText = + (smallerText == null || + smallerText.length == + replyMessage + .metadata!["body"] + .length) + ? replyMessage.metadata!["body"] + : "$smallerText..."; + + return InkWell( + onTap: () => onTapReply?.call(replyMessage), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + AvatarOrHash( + Uri.tryParse( + replyMessage.metadata?["avatarUrl"] ?? + "", + ), + replyMessage.metadata?["displayName"] ?? + "", + height: 16, + ), + Flexible( + child: Text( + replyMessage + .metadata?["displayName"] ?? + replyMessage.authorId, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: Text( + replyText, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelMedium, + maxLines: 1, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); +} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 98b48d5..839109f 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -19,7 +19,7 @@ import "package:nexus/widgets/chat_page/member_list.dart"; import "package:nexus/widgets/chat_page/message_wrapper.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; import "package:nexus/widgets/chat_page/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/top_widget.dart"; +import "package:nexus/widgets/chat_page/reply_widget.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/loading.dart"; // import "package:dynamic_polls/dynamic_polls.dart"; @@ -260,6 +260,7 @@ class RoomChat extends HookConsumerWidget { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => TextMessageWrapper( + room: room, message, content: message.text, groupStatus: groupStatus, @@ -277,6 +278,7 @@ class RoomChat extends HookConsumerWidget { MessageGroupStatus? groupStatus, }) => TextMessageWrapper( message, + room: room, content: message.text, groupStatus: groupStatus, onTapReply: notifier.scrollToMessage, @@ -307,7 +309,8 @@ class RoomChat extends HookConsumerWidget { ), ), child: FlyerChatFileMessage( - topWidget: TopWidget( + topWidget: ReplyWidget( + room: room, message, onTapReply: notifier.scrollToMessage, groupStatus: groupStatus, diff --git a/lib/widgets/chat_page/text_message_wrapper.dart b/lib/widgets/chat_page/text_message_wrapper.dart index 4873f84..9734a34 100644 --- a/lib/widgets/chat_page/text_message_wrapper.dart +++ b/lib/widgets/chat_page/text_message_wrapper.dart @@ -1,13 +1,15 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/top_widget.dart"; +import "package:nexus/widgets/chat_page/reply_widget.dart"; class TextMessageWrapper extends StatelessWidget { final Message message; final String? content; + final Room room; final MessageGroupStatus? groupStatus; final Future Function(Message oldMessage, Message newMessage) updateMessage; @@ -19,6 +21,7 @@ class TextMessageWrapper extends StatelessWidget { this.message, { this.content, this.onTapReply, + required this.room, required this.updateMessage, required this.groupStatus, required this.isSentByMe, @@ -46,8 +49,9 @@ class TextMessageWrapper extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TopWidget( + ReplyWidget( message, + room: room, groupStatus: groupStatus, onTapReply: onTapReply, ), diff --git a/lib/widgets/chat_page/top_widget.dart b/lib/widgets/chat_page/top_widget.dart deleted file mode 100644 index 4e82a9e..0000000 --- a/lib/widgets/chat_page/top_widget.dart +++ /dev/null @@ -1,91 +0,0 @@ -import "dart:math"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; - -typedef OnTapReply = void Function(Message message)?; - -class TopWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final MessageGroupStatus? groupStatus; - final OnTapReply onTapReply; - const TopWidget( - this.message, { - required this.groupStatus, - this.onTapReply, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final replyMessage = message.metadata?["reply"] as Message?; - - if (replyMessage == null) return SizedBox.shrink(); - - final smallerText = - message is TextMessage && replyMessage.metadata?["body"] != null - ? replyMessage.metadata!["body"].substring( - 0, - min( - max( - max( - (message as TextMessage).text.length - - (replyMessage.metadata?["displayName"] as String).length - - 5, - message.metadata?["displayName"].length, - ), - 5, - ), - replyMessage.metadata!["body"].length, - ), - ) - : null; - final replyText = - (smallerText == null || - smallerText.length == replyMessage.metadata!["body"].length) - ? replyMessage.metadata!["body"] - : "$smallerText..."; - - return Padding( - padding: EdgeInsets.only(bottom: 12), - child: InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Quoted( - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - AvatarOrHash( - Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""), - replyMessage.metadata?["displayName"] ?? "", - height: 16, - ), - Flexible( - child: Text( - replyMessage.metadata?["displayName"] ?? - replyMessage.authorId, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Flexible( - child: Text( - replyText, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index aadc43c..9bb2858 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -1,13 +1,14 @@ import "package:flutter/material.dart"; class Loading extends StatelessWidget { - const Loading({super.key}); + final double? height; + const Loading({this.height, super.key}); @override - Widget build(BuildContext context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator(), - ), - ); + Widget build(BuildContext context) => Center( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox(height: height, child: CircularProgressIndicator()), + ), + ); }