From 2a4525a78f53fc0a1e2846044cf2498aac06581f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 12 Nov 2025 11:33:17 -0500 Subject: [PATCH] reply rendering --- lib/controllers/message_controller.dart | 21 +++++ lib/controllers/room_chat_controller.dart | 77 +--------------- lib/helpers/extension_helper.dart | 82 +++++++++++++++++ lib/main.dart | 4 +- lib/widgets/room_chat.dart | 44 ++------- lib/widgets/top_widget.dart | 107 ++++++++++++++++++++++ 6 files changed, 226 insertions(+), 109 deletions(-) create mode 100644 lib/controllers/message_controller.dart create mode 100644 lib/widgets/top_widget.dart diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart new file mode 100644 index 0000000..6fdeb45 --- /dev/null +++ b/lib/controllers/message_controller.dart @@ -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 { + final String id; + MessageController(this.id); + + @override + Future 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.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 01e1b6f..f0b38c5 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -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 { RoomChatController(this.room); @@ -13,7 +14,7 @@ class RoomChatController extends AsyncNotifier { 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 { 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 { return controller.updateMessage(message, newMessage); } - Future 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 send(String message) async { await room.sendTextEvent(message); } diff --git a/lib/helpers/extension_helper.dart b/lib/helpers/extension_helper.dart index 739c602..ebe62af 100644 --- a/lib/helpers/extension_helper.dart +++ b/lib/helpers/extension_helper.dart @@ -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 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, + ), + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index d4b777b..adb288d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 7bf23e2..44002e5 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -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,31 +110,9 @@ 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, - headers: headers, - ), - Text( - message.metadata?["displayName"] ?? - message.authorId, - style: theme.textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), + topWidget: TopWidget( + message, + headers: headers, ), message: message.copyWith( text: message.text.replaceAllMapped( @@ -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, ), diff --git a/lib/widgets/top_widget.dart b/lib/widgets/top_widget.dart new file mode 100644 index 0000000..582755e --- /dev/null +++ b/lib/widgets/top_widget.dart @@ -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 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), + ], + ); +}