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: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<Message?> {
final MessageConfig config;
@ -18,7 +16,6 @@ class MessageController extends AsyncNotifier<Message?> {
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<Message?> {
) ??
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<Message?> {
"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<Message?> {
// 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,

View file

@ -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<String, Object?> json) =>
_$MessageConfigFromJson(json);
}

View file

@ -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<String, Object?> 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/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,

View file

@ -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<void> 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,
),

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";
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()),
),
);
}