load replies at render time

This commit is contained in:
Henry Hiles 2026-03-19 11:26:00 -04:00
commit 62f8e675a4
No known key found for this signature in database
9 changed files with 208 additions and 124 deletions

View file

@ -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<Event?> {
final GetEventRequest request;
EventController(this.request);
@override
Future<Event?> build() async {
final client = ref.watch(ClientController.provider.notifier);
return await client.getEvent(request).onError((_, _) => null);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<EventController, Event?, GetEventRequest>(
EventController.new,
);
}

View file

@ -1,12 +1,10 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.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/client_state_controller.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/message_config.dart"; import "package:nexus/models/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart";
class MessageController extends AsyncNotifier<Message?> { class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config; final MessageConfig config;
@ -18,7 +16,6 @@ class MessageController extends AsyncNotifier<Message?> {
if (config.event.relationType == "m.replace" && !config.includeEdits) { if (config.event.relationType == "m.replace" && !config.includeEdits) {
return null; return null;
} }
final client = ref.watch(ClientController.provider.notifier);
if (!ref.mounted) return null; if (!ref.mounted) return null;
final event = config.event.lastEditRowId == null final event = config.event.lastEditRowId == null
@ -28,17 +25,9 @@ class MessageController extends AsyncNotifier<Message?> {
) ?? ) ??
config.event; 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; 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( final author = members.firstWhereOrNull(
(member) => member.stateKey == event.authorId, (member) => member.stateKey == event.authorId,
); );
@ -59,16 +48,6 @@ class MessageController extends AsyncNotifier<Message?> {
"body": config.event.redactedBy == null "body": config.event.redactedBy == null
? (newContent?["body"] ?? content["body"] ?? "") ? (newContent?["body"] ?? content["body"] ?? "")
: "Deleted Message", : "Deleted Message",
if (replyEvent != null)
"reply": await ref.watch(
MessageController.provider(
MessageConfig(
event: replyEvent,
room: config.room,
alwaysReturn: true,
),
).future,
),
"flashing": false, "flashing": false,
"timelineId": event.timelineRowId, "timelineId": event.timelineRowId,
"big": event.localContent?.bigEmoji == true, "big": event.localContent?.bigEmoji == true,
@ -102,6 +81,9 @@ class MessageController extends AsyncNotifier<Message?> {
// RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "",
// ); // );
final replyId =
config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"];
final asText = final asText =
Message.text( Message.text(
metadata: metadata, metadata: metadata,

View file

@ -6,6 +6,7 @@ part "message_config.g.dart";
@freezed @freezed
abstract class MessageConfig with _$MessageConfig { abstract class MessageConfig with _$MessageConfig {
const MessageConfig._();
const factory MessageConfig({ const factory MessageConfig({
@Default(false) bool alwaysReturn, @Default(false) bool alwaysReturn,
@Default(false) bool includeEdits, @Default(false) bool includeEdits,
@ -13,6 +14,15 @@ abstract class MessageConfig with _$MessageConfig {
required Event event, required Event event,
}) = _MessageConfig; }) = _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<String, Object?> json) => factory MessageConfig.fromJson(Map<String, Object?> json) =>
_$MessageConfigFromJson(json); _$MessageConfigFromJson(json);
} }

View file

@ -18,6 +18,15 @@ abstract class GetEventRequest with _$GetEventRequest {
"unredact": unredact, "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<String, Object?> json) => factory GetEventRequest.fromJson(Map<String, Object?> json) =>
_$GetEventRequestFromJson(json); _$GetEventRequestFromJson(json);
} }

View file

@ -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,
),
),
],
),
);
},
),
),
),
);
}

View file

@ -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/message_wrapper.dart";
import "package:nexus/widgets/chat_page/room_appbar.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/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/form_text_input.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart"; // import "package:dynamic_polls/dynamic_polls.dart";
@ -260,6 +260,7 @@ class RoomChat extends HookConsumerWidget {
required bool isSentByMe, required bool isSentByMe,
MessageGroupStatus? groupStatus, MessageGroupStatus? groupStatus,
}) => TextMessageWrapper( }) => TextMessageWrapper(
room: room,
message, message,
content: message.text, content: message.text,
groupStatus: groupStatus, groupStatus: groupStatus,
@ -277,6 +278,7 @@ class RoomChat extends HookConsumerWidget {
MessageGroupStatus? groupStatus, MessageGroupStatus? groupStatus,
}) => TextMessageWrapper( }) => TextMessageWrapper(
message, message,
room: room,
content: message.text, content: message.text,
groupStatus: groupStatus, groupStatus: groupStatus,
onTapReply: notifier.scrollToMessage, onTapReply: notifier.scrollToMessage,
@ -307,7 +309,8 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
child: FlyerChatFileMessage( child: FlyerChatFileMessage(
topWidget: TopWidget( topWidget: ReplyWidget(
room: room,
message, message,
onTapReply: notifier.scrollToMessage, onTapReply: notifier.scrollToMessage,
groupStatus: groupStatus, groupStatus: groupStatus,

View file

@ -1,13 +1,15 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.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/html/html.dart";
import "package:nexus/widgets/chat_page/message_wrapper.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 { class TextMessageWrapper extends StatelessWidget {
final Message message; final Message message;
final String? content; final String? content;
final Room room;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
final Future<void> Function(Message oldMessage, Message newMessage) final Future<void> Function(Message oldMessage, Message newMessage)
updateMessage; updateMessage;
@ -19,6 +21,7 @@ class TextMessageWrapper extends StatelessWidget {
this.message, { this.message, {
this.content, this.content,
this.onTapReply, this.onTapReply,
required this.room,
required this.updateMessage, required this.updateMessage,
required this.groupStatus, required this.groupStatus,
required this.isSentByMe, required this.isSentByMe,
@ -46,8 +49,9 @@ class TextMessageWrapper extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TopWidget( ReplyWidget(
message, message,
room: room,
groupStatus: groupStatus, groupStatus: groupStatus,
onTapReply: onTapReply, onTapReply: onTapReply,
), ),

View file

@ -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,
),
),
],
),
),
),
);
}
}

View file

@ -1,13 +1,14 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
class Loading extends StatelessWidget { class Loading extends StatelessWidget {
const Loading({super.key}); final double? height;
const Loading({this.height, super.key});
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: CircularProgressIndicator(), child: SizedBox(height: height, child: CircularProgressIndicator()),
), ),
); );
} }