reply rendering

This commit is contained in:
Henry Hiles 2025-11-12 11:33:17 -05:00
commit 2a4525a78f
No known key found for this signature in database
6 changed files with 226 additions and 109 deletions

View file

@ -0,0 +1,21 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
class MessageController extends AsyncNotifier<TextMessage?> {
final String id;
MessageController(this.id);
@override
Future<TextMessage?> build() async {
final room = await ref.watch(CurrentRoomController.provider.future);
final event = await room.roomData.getEventById(id);
return (await event?.toMessage(mustBeText: true)) as TextMessage;
}
static final provider =
AsyncNotifierProvider.family<MessageController, TextMessage?, String>(
MessageController.new,
);
}

View file

@ -3,6 +3,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/helpers/extension_helper.dart";
class RoomChatController extends AsyncNotifier<ChatController> {
RoomChatController(this.room);
@ -13,7 +14,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
final timeline = await room.getTimeline();
room.client.onTimelineEvent.stream.listen((event) async {
if (event.roomId != room.id) return;
final message = await toMessage(event);
final message = await event.toMessage();
if (message != null) {
await insertMessage(message);
}
@ -21,7 +22,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
return InMemoryChatController(
messages: (await Future.wait(
timeline.events.map(toMessage),
timeline.events.map((event) => event.toMessage()),
)).toList().reversed.nonNulls.toList(),
);
}
@ -42,78 +43,6 @@ class RoomChatController extends AsyncNotifier<ChatController> {
return controller.updateMessage(message, newMessage);
}
Future<Message?> toMessage(Event event) async {
final replyId = event.relationshipType == RelationshipTypes.reply
? event.relationshipEventId
: null;
final metadata = {
"eventType": event.type,
"displayName": event.senderFromMemoryOrFallback.displayName,
"txnId": event.transactionId,
};
return event.redacted
? Message.text(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: "~~This message has been redacted.~~",
deletedAt: event.redactedBecause?.originServerTs,
)
: switch (event.type) {
EventTypes.Message => switch (event.messageType) {
MessageTypes.Image => Message.image(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
source: (await event.getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
MessageTypes.Audio => Message.audio(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: event.body,
replyToMessageId: replyId,
source: (await event.getAttachmentUri()).toString(),
deliveredAt: event.originServerTs,
duration: Duration(hours: 1),
),
MessageTypes.File => Message.file(
name: event.content["filename"].toString(),
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
source: (await event.getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
_ => Message.text(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: event.body,
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
},
EventTypes.RoomMember => Message.system(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text:
"${event.senderFromMemoryOrFallback.calcDisplayname()} joined the room.",
),
EventTypes.Redaction => null,
_ => Message.unsupported(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
replyToMessageId: replyId,
),
};
}
Future<void> send(String message) async {
await room.sendTextEvent(message);
}

View file

@ -1,4 +1,5 @@
import "package:flutter/widgets.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/models/full_room.dart";
@ -37,3 +38,84 @@ extension GetImage on Uri {
);
}
}
extension ToMessage on Event {
Future<Message?> toMessage({bool mustBeText = false}) async {
final replyId = relationshipType == RelationshipTypes.reply
? relationshipEventId
: null;
final metadata = {
"eventType": type,
"displayName": senderFromMemoryOrFallback.displayName,
"txnId": transactionId,
};
if (redacted) {
return Message.text(
metadata: metadata,
id: eventId,
authorId: senderId,
text: "~~This message has been redacted.~~",
deletedAt: redactedBecause?.originServerTs,
);
}
final asText = Message.text(
metadata: metadata,
id: eventId,
authorId: senderId,
text: body,
replyToMessageId: replyId,
deliveredAt: originServerTs,
);
if (mustBeText) return asText;
return switch (type) {
EventTypes.Message => switch (messageType) {
MessageTypes.Image => Message.image(
metadata: metadata,
id: eventId,
authorId: senderId,
source: (await getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
),
MessageTypes.Audio => Message.audio(
metadata: metadata,
id: eventId,
authorId: senderId,
text: body,
replyToMessageId: replyId,
source: (await getAttachmentUri()).toString(),
deliveredAt: originServerTs,
duration: Duration(hours: 1),
),
MessageTypes.File => Message.file(
name: content["filename"].toString(),
metadata: metadata,
id: eventId,
authorId: senderId,
source: (await getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
),
_ => asText,
},
EventTypes.RoomMember => Message.system(
metadata: metadata,
id: eventId,
authorId: senderId,
text:
"${senderFromMemoryOrFallback.calcDisplayname()} joined the room.",
),
EventTypes.Redaction => null,
_ => Message.unsupported(
metadata: metadata,
id: eventId,
authorId: senderId,
replyToMessageId: replyId,
),
};
}
}

View file

@ -8,7 +8,9 @@ import "package:dynamic_system_colors/dynamic_system_colors.dart";
import "package:window_size/window_size.dart";
void main() async {
ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.3);
ScaledWidgetsFlutterBinding.ensureInitialized(
scaleFactor: (size) => size.width > 1080 ? 1.3 : 1,
);
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(

View file

@ -14,6 +14,7 @@ import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/top_widget.dart";
import "package:nexus/widgets/room_avatar.dart";
class RoomChat extends HookConsumerWidget {
@ -86,16 +87,11 @@ class RoomChat extends HookConsumerWidget {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? FlyerChatTextMessage(
message: TextMessage(
id: message.id,
authorId: message.authorId,
text:
"Unsupported message type: ${message.metadata?["eventType"]}",
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
receivedBackgroundColor: Colors.red,
sentBackgroundColor: Colors.red,
index: index,
)
: SizedBox.shrink(),
textMessageBuilder:
@ -114,32 +110,10 @@ class RoomChat extends HookConsumerWidget {
SizedBox(height: 8),
FlyerChatTextMessage(
topWidget: Padding(
padding: EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(
userId: message.authorId,
topWidget: TopWidget(
message,
headers: headers,
),
Text(
message.metadata?["displayName"] ??
message.authorId,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
@ -188,6 +162,7 @@ class RoomChat extends HookConsumerWidget {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(message, headers: headers),
message: message,
index: index,
headers: headers,
@ -204,6 +179,7 @@ class RoomChat extends HookConsumerWidget {
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(message, headers: headers),
message: message,
index: index,
),

107
lib/widgets/top_widget.dart Normal file
View file

@ -0,0 +1,107 @@
import "dart:math";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
class TopWidget extends ConsumerWidget {
final Message message;
final Map<String, String> headers;
const TopWidget(this.message, {required this.headers, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.replyToMessageId != null) ...[
ref
.watch(MessageController.provider(message.replyToMessageId!))
.betterWhen(
loading: SizedBox.shrink,
data: (replyMessage) {
if (replyMessage == null) return SizedBox.shrink();
final replyText = message is TextMessage
? replyMessage.text.substring(
0,
min(
max(
min(
(message as TextMessage).text.length - 20,
replyMessage.text.length,
),
40,
),
replyMessage.text.length,
),
)
: replyMessage.text;
return InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Scroll to message
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
width: 4,
color: Theme.of(context).dividerColor,
),
),
),
child: Padding(
padding: EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(
userId: replyMessage.authorId,
headers: headers,
size: 16,
),
Text(
replyMessage.metadata?["displayName"] ??
replyMessage.authorId,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Flexible(
child: Text(
replyText,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
),
),
],
),
),
),
);
},
),
SizedBox(height: 12),
],
InkWell(
onTap: () =>
showAboutDialog(context: context), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(userId: message.authorId, headers: headers),
Text(
message.metadata?["displayName"] ?? message.authorId,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
),
),
SizedBox(height: 4),
],
);
}