From 385cc833f2e3e128d50e58bdafd01530272cbf25 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 27 Nov 2024 15:16:21 +0500 Subject: [PATCH 01/15] provider if we should show avatar on the messages list --- .../chat_room_messages_provider.dart | 27 +++++++++++++++++++ .../widgets/events/chat_event_widget.dart | 24 ++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index e8efaa8aadfe..98f99186998b 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -49,3 +49,30 @@ final renderableChatMessagesProvider = return _supportedTypes.contains(msg.eventItem()?.eventType()); }).toList(); }); + +// Provider to check if we should show avatar by comparing with the next message +final shouldShowAvatarProvider = Provider.family( + (ref, roomMsgId) { + final roomId = roomMsgId.$1; + final eventId = roomMsgId.$2; + final messages = ref.watch(renderableChatMessagesProvider(roomId)); + final currentIndex = messages.indexOf(eventId); + + // Always show avatar for the first message (last in the list) + if (currentIndex == messages.length - 1) return true; + + // Get current and next message + final currentMsg = ref.watch(chatRoomMessageProvider(roomMsgId)); + final nextMsg = ref.watch( + chatRoomMessageProvider((roomId, messages[currentIndex + 1])), + ); + + if (currentMsg == null || nextMsg == null) return true; + + final currentSender = currentMsg.eventItem()?.sender(); + final nextSender = nextMsg.eventItem()?.sender(); + + // Show avatar if next message is from a different sender + return currentSender != nextSender; + }, +); diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart b/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart index de8a92df526e..4809dbbca10b 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart @@ -1,4 +1,6 @@ +import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_avatar/acter_avatar.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -24,6 +26,8 @@ class ChatEventWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final msg = message ?? ref.watch(chatRoomMessageProvider((roomId, eventId))); + final showAvatar = ref.watch(shouldShowAvatarProvider((roomId, eventId))); + if (msg == null) { _log.severe('Msg not found $roomId $eventId'); return const SizedBox.shrink(); @@ -40,7 +44,7 @@ class ChatEventWidget extends ConsumerWidget { return renderVirtual(msg, virtual); } - return renderEvent(msg, inner); + return renderEvent(showAvatar: showAvatar, msg: msg, item: inner, ref: ref); } Widget renderVirtual(RoomMessage msg, RoomVirtualItem virtual) { @@ -48,11 +52,25 @@ class ChatEventWidget extends ConsumerWidget { return const SizedBox.shrink(); } - Widget renderEvent(RoomMessage msg, RoomEventItem item) { + Widget renderEvent({ + required bool showAvatar, + required RoomMessage msg, + required RoomEventItem item, + required WidgetRef ref, + }) { + final avatarInfo = ref.watch( + memberAvatarInfoProvider( + ( + roomId: roomId, + userId: item.sender(), + ), + ), + ); + final options = AvatarOptions.DM(avatarInfo, size: 18); // TODO: render a regular timeline event return Wrap( children: [ - Text(item.sender()), + showAvatar ? ActerAvatar(options: options) : const SizedBox(), const Text(':'), Text(item.msgContent()?.body() ?? 'no body'), ], From 2976dbaa7583c4e574cf5bd601122b726c12fbd4 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 28 Nov 2024 17:48:55 +0500 Subject: [PATCH 02/15] support media and text message events --- .../chat_room_messages_provider.dart | 8 +- .../features/chat_ng/widgets/chat_bubble.dart | 70 +++++++ .../chat_ng/widgets/chat_messages.dart | 13 +- .../chat_ng/widgets/events/chat_event.dart | 172 ++++++++++++++++++ .../widgets/events/chat_event_widget.dart | 79 -------- .../widgets/events/file_message_event.dart | 103 +++++++++++ .../widgets/events/image_message_event.dart | 167 +++++++++++++++++ .../widgets/events/text_message_event.dart | 48 +++++ .../widgets/events/video_message_event.dart | 169 +++++++++++++++++ 9 files changed, 742 insertions(+), 87 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/chat_bubble.dart create mode 100644 app/lib/features/chat_ng/widgets/events/chat_event.dart delete mode 100644 app/lib/features/chat_ng/widgets/events/chat_event_widget.dart create mode 100644 app/lib/features/chat_ng/widgets/events/file_message_event.dart create mode 100644 app/lib/features/chat_ng/widgets/events/image_message_event.dart create mode 100644 app/lib/features/chat_ng/widgets/events/text_message_event.dart create mode 100644 app/lib/features/chat_ng/widgets/events/video_message_event.dart diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 98f99186998b..fc288b8a40de 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -6,6 +6,12 @@ import 'package:logging/logging.dart'; import 'package:riverpod/riverpod.dart'; final _log = Logger('a3::chat::message_provider'); +const _supportedTypes = [ + 'm.room.member', + 'm.room.message', + 'm.room.redaction', + 'm.room.encrypted', +]; typedef RoomMsgId = (String roomId, String uniqueId); @@ -23,8 +29,6 @@ final chatRoomMessageProvider = final showHiddenMessages = StateProvider((ref) => false); -const _supportedTypes = ['m.room.message']; - final animatedListChatMessagesProvider = StateProvider.family, String>( (ref, roomId) => ref.watch(chatStateProvider(roomId).notifier).animatedList, diff --git a/app/lib/features/chat_ng/widgets/chat_bubble.dart b/app/lib/features/chat_ng/widgets/chat_bubble.dart new file mode 100644 index 000000000000..7fc0f1d00577 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_bubble.dart @@ -0,0 +1,70 @@ +import 'package:acter/common/themes/acter_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:acter/common/extensions/options.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +// Chat Bubble UI +class ChatBubble extends StatelessWidget { + final int? messageWidth; + // inner content + final Widget child; + final bool isUser; + final bool showAvatar; + final bool wasEdited; + + const ChatBubble({ + super.key, + required this.child, + this.messageWidth, + this.isUser = false, + this.showAvatar = true, + this.wasEdited = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final chatTheme = Theme.of(context).chatTheme; + final size = MediaQuery.sizeOf(context); + final msgWidth = messageWidth.map((w) => w.toDouble()); + return Container( + margin: EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + constraints: BoxConstraints(maxWidth: msgWidth ?? size.width), + width: msgWidth, + decoration: BoxDecoration( + color: isUser + ? theme.colorScheme.primary + : theme.colorScheme.surface, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isUser || !showAvatar ? 16 : 4), + bottomRight: Radius.circular(isUser ? 4 : 16), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: child, + ), + ), + Visibility( + visible: wasEdited, + child: Text( + L10n.of(context).edited, + style: chatTheme.emptyChatPlaceholderTextStyle + .copyWith(fontSize: 12), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index a9a9e2966aa4..0abbc9502177 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -1,5 +1,5 @@ import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; -import 'package:acter/features/chat_ng/widgets/events/chat_event_widget.dart'; +import 'package:acter/features/chat_ng/widgets/events/chat_event.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,12 +15,13 @@ class ChatMessages extends ConsumerWidget { return AnimatedList( initialItemCount: messages.length, - reverse: true, key: animatedListKey, - itemBuilder: (_, index, animation) => ChatEventWidget( - roomId: roomId, - eventId: messages[index], - animation: animation, + itemBuilder: (_, index, animation) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: ChatEvent( + roomId: roomId, + eventId: messages[index], + ), ), ); } diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart new file mode 100644 index 000000000000..1afae640fddf --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -0,0 +1,172 @@ +import 'package:acter/common/providers/common_providers.dart'; +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; +import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; +import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; +import 'package:acter_avatar/acter_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::chat::widgets::room_message'); + +class ChatEvent extends ConsumerWidget { + final String roomId; + final String eventId; + + const ChatEvent({ + super.key, + required this.roomId, + required this.eventId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final msg = ref.watch(chatRoomMessageProvider((roomId, eventId))); + + if (msg == null) { + _log.severe('Msg not found $roomId $eventId'); + return const SizedBox.shrink(); + } + + final inner = msg.eventItem(); + if (inner == null) { + final virtual = msg.virtualItem(); + if (virtual == null) { + _log.severe( + 'Event is neither virtual nor full event: $roomId $eventId', + ); + return const SizedBox.shrink(); + } + return renderVirtual(msg, virtual); + } + + return renderEvent(msg: msg, item: inner, ref: ref); + } + + Widget renderVirtual(RoomMessage msg, RoomVirtualItem virtual) { + // TODO: virtual Objects support + return const SizedBox.shrink(); + } + + Widget renderEvent({ + required RoomMessage msg, + required RoomEventItem item, + required WidgetRef ref, + }) { + final showAvatar = ref.watch(shouldShowAvatarProvider((roomId, eventId))); + final avatarInfo = ref.watch( + memberAvatarInfoProvider( + ( + roomId: roomId, + userId: item.sender(), + ), + ), + ); + final options = AvatarOptions.DM(avatarInfo, size: 14); + final myId = ref.watch(myUserIdStrProvider); + final isUser = myId == item.sender(); + // TODO: render a regular timeline event + return Row( + mainAxisAlignment: + !isUser ? MainAxisAlignment.start : MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + (showAvatar && !isUser) + ? Padding( + padding: const EdgeInsets.only(left: 8), + child: ActerAvatar(options: options), + ) + : const SizedBox(width: 40), + Flexible( + child: _buildEventItem(msg, item, isUser, showAvatar), + ), + ], + ); + } + + Widget _buildEventItem( + RoomMessage msg, + RoomEventItem item, + bool isUser, + bool showAvatar, + ) { + final eventType = item.eventType(); + switch (eventType) { + case 'm.room.message': + return _buildMsgEventItem(roomId, item, msg, isUser, showAvatar); + case 'm.room.redaction': + return ChatBubble( + isUser: isUser, + showAvatar: showAvatar, + child: RedactedMessageWidget(), + ); + case 'm.room.encrypted': + return ChatBubble( + isUser: isUser, + showAvatar: showAvatar, + child: EncryptedMessageWidget(), + ); + default: + return _buildUnsupportedMessage(eventType); + } + } + + Widget _buildMsgEventItem( + String roomId, + RoomEventItem item, + RoomMessage msg, + bool isUser, + bool showAvatar, + ) { + final msgType = item.msgType(); + final content = item.msgContent(); + final messageId = msg.uniqueId(); + // shouldn't happen but in case return empty + if (msgType == null || content == null) return const SizedBox.shrink(); + + switch (msgType) { + case 'm.emote': + case 'm.text': + return TextMessageEvent( + roomId: roomId, + content: content, + isUser: isUser, + showAvatar: showAvatar, + ); + case 'm.image': + return ImageMessageEvent( + messageId: messageId, + roomId: roomId, + content: content, + ); + case 'm.video': + return VideoMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ); + case 'm.file': + return FileMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ); + default: + return _buildUnsupportedMessage(msgType); + } + } + + Widget _buildUnsupportedMessage(String? msgtype) { + return Text( + 'Unsupported message type: $msgtype', + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart b/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart deleted file mode 100644 index 4809dbbca10b..000000000000 --- a/app/lib/features/chat_ng/widgets/events/chat_event_widget.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:acter/common/providers/room_providers.dart'; -import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; -import 'package:acter_avatar/acter_avatar.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('a3::chat::widgets::room_message'); - -class ChatEventWidget extends ConsumerWidget { - final RoomMessage? message; - final String roomId; - final String eventId; - final Animation? animation; - - const ChatEventWidget({ - super.key, - required this.roomId, - required this.eventId, - this.message, - this.animation, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final msg = - message ?? ref.watch(chatRoomMessageProvider((roomId, eventId))); - final showAvatar = ref.watch(shouldShowAvatarProvider((roomId, eventId))); - - if (msg == null) { - _log.severe('Msg not found $roomId $eventId'); - return const SizedBox.shrink(); - } - final inner = msg.eventItem(); - if (inner == null) { - final virtual = msg.virtualItem(); - if (virtual == null) { - _log.severe( - 'Event is neither virtual nor full event: $roomId $eventId', - ); - return const SizedBox.shrink(); - } - return renderVirtual(msg, virtual); - } - - return renderEvent(showAvatar: showAvatar, msg: msg, item: inner, ref: ref); - } - - Widget renderVirtual(RoomMessage msg, RoomVirtualItem virtual) { - // TODO: virtual Objects support - return const SizedBox.shrink(); - } - - Widget renderEvent({ - required bool showAvatar, - required RoomMessage msg, - required RoomEventItem item, - required WidgetRef ref, - }) { - final avatarInfo = ref.watch( - memberAvatarInfoProvider( - ( - roomId: roomId, - userId: item.sender(), - ), - ), - ); - final options = AvatarOptions.DM(avatarInfo, size: 18); - // TODO: render a regular timeline event - return Wrap( - children: [ - showAvatar ? ActerAvatar(options: options) : const SizedBox(), - const Text(':'), - Text(item.msgContent()?.body() ?? 'no body'), - ], - ); - } -} diff --git a/app/lib/features/chat_ng/widgets/events/file_message_event.dart b/app/lib/features/chat_ng/widgets/events/file_message_event.dart new file mode 100644 index 000000000000..564a1dff1edc --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/file_message_event.dart @@ -0,0 +1,103 @@ +import 'package:acter/common/models/types.dart'; +import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/files/actions/file_share.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +class FileMessageEvent extends ConsumerWidget { + final String roomId; + final String messageId; + final String? eventId; + final MsgContent content; + + const FileMessageEvent({ + super.key, + required this.roomId, + required this.messageId, + required this.content, + this.eventId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ChatMessageInfo messageInfo = + (messageId: eventId ?? messageId, roomId: roomId); + final mediaState = ref.watch(mediaChatStateProvider(messageInfo)); + return InkWell( + onTap: () async { + final mediaFile = + ref.read(mediaChatStateProvider(messageInfo)).mediaFile; + if (mediaFile != null) { + await openFileShareDialog( + context: context, + file: mediaFile, + ); + } else { + final notifier = + ref.read(mediaChatStateProvider(messageInfo).notifier); + await notifier.downloadMedia(); + } + }, + child: Container( + padding: const EdgeInsets.all(20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + getFileIcon(context), + const SizedBox(width: 20), + fileInfoUI(context), + const SizedBox(width: 10), + if (mediaState.mediaChatLoadingState.isLoading || + mediaState.isDownloading) + const CircularProgressIndicator() + else if (mediaState.mediaFile == null) + const Icon(Icons.download), + ], + ), + ), + ); + } + + Widget getFileIcon(BuildContext context) { + final extension = p.extension(content.body()); + IconData iconData = switch (extension) { + '.png' || '.jpg' || '.jpeg' => Atlas.file_image, + '.pdf' => Icons.picture_as_pdf, + '.doc' => Atlas.file, + '.mp4' => Atlas.file_video, + '.mp3' => Atlas.music_file, + '.rtf' || '.txt' => Atlas.lines_file, + _ => Atlas.lines_file, + }; + return Icon(iconData, size: 28); + } + + Widget fileInfoUI(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final msgSize = content.size(); + if (msgSize == null) return const SizedBox.shrink(); + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content.body(), + style: textTheme.labelLarge, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + formatBytes(msgSize.truncate()), + style: textTheme.labelMedium?.copyWith(color: colorScheme.primary), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/image_message_event.dart b/app/lib/features/chat_ng/widgets/events/image_message_event.dart new file mode 100644 index 000000000000..91dd092784a7 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/image_message_event.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:acter/common/models/types.dart'; +import 'package:acter/common/toolkit/errors/inline_error_button.dart'; +import 'package:acter/common/widgets/image_dialog.dart'; +import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +class ImageMessageEvent extends ConsumerWidget { + final String roomId; + final String messageId; + final String? eventId; + final MsgContent content; + + const ImageMessageEvent({ + super.key, + required this.messageId, + required this.roomId, + required this.content, + this.eventId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ChatMessageInfo messageInfo = + (messageId: eventId ?? messageId, roomId: roomId); + final mediaState = ref.watch(mediaChatStateProvider(messageInfo)); + if (mediaState.mediaChatLoadingState.isLoading || + mediaState.isDownloading) { + return loadingIndication(context); + } + final mediaFile = mediaState.mediaFile; + if (mediaFile == null) { + return imagePlaceholder(context, roomId, ref); + } else { + return imageUI(context, ref, mediaFile); + } + } + + Widget loadingIndication(BuildContext context) { + return const SizedBox( + width: 150, + height: 150, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + Widget imagePlaceholder( + BuildContext context, + String roomId, + WidgetRef ref, + ) { + final msgSize = content.size(); + if (msgSize == null) return const SizedBox.shrink(); + return InkWell( + onTap: () async { + final notifier = ref.read( + mediaChatStateProvider( + (messageId: messageId, roomId: roomId), + ).notifier, + ); + await notifier.downloadMedia(); + }, + child: SizedBox( + width: 200, + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.download, + size: 28, + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.image, + size: 18, + ), + const SizedBox(width: 5), + Text( + formatBytes(msgSize.truncate()), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget imageUI(BuildContext context, WidgetRef ref, File mediaFile) { + return InkWell( + onTap: () { + showAdaptiveDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) => ImageDialog( + title: content.body(), + imageFile: mediaFile, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 300, + maxHeight: 300, + ), + child: imageFileView(context, ref, mediaFile), + ), + ), + ); + } + + Widget imageFileView(BuildContext context, WidgetRef ref, File mediaFile) { + return Image.file( + mediaFile, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) { + return child; + } + return AnimatedOpacity( + opacity: frame == null ? 0 : 1, + duration: const Duration(seconds: 1), + curve: Curves.easeOut, + child: child, + ); + }, + errorBuilder: (context, error, stack) => ActerInlineErrorButton.icon( + icon: Icon(PhosphorIcons.imageBroken()), + error: error, + stack: stack, + textBuilder: L10n.of(context).couldNotLoadImage, + onRetryTap: () { + final ChatMessageInfo messageInfo = + (messageId: eventId ?? messageId, roomId: roomId); + ref.invalidate(mediaChatStateProvider(messageInfo)); + }, + ), + fit: BoxFit.cover, + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/text_message_event.dart b/app/lib/features/chat_ng/widgets/events/text_message_event.dart new file mode 100644 index 000000000000..f5cfa5db2f81 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/text_message_event.dart @@ -0,0 +1,48 @@ +import 'package:acter/features/chat/widgets/pill_builder.dart'; +import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_matrix_html/flutter_html.dart'; +import 'package:flutter_matrix_html/text_parser.dart'; + +class TextMessageEvent extends StatelessWidget { + final String roomId; + final MsgContent content; + final bool isUser; + final bool showAvatar; + final bool wasEdited; + + const TextMessageEvent({ + super.key, + required this.content, + required this.isUser, + required this.showAvatar, + required this.roomId, + this.wasEdited = false, + }); + + @override + Widget build(BuildContext context) { + final body = content.formattedBody() ?? content.body(); + return ChatBubble( + isUser: isUser, + showAvatar: showAvatar, + wasEdited: wasEdited, + child: Html( + shrinkToFit: true, + pillBuilder: ({ + required String identifier, + required String url, + OnPillTap? onTap, + }) => + ActerPillBuilder( + identifier: identifier, + uri: url, + roomId: roomId, + ), + renderNewlines: true, + data: body, + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/video_message_event.dart b/app/lib/features/chat_ng/widgets/events/video_message_event.dart new file mode 100644 index 000000000000..839a71be6c1e --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/video_message_event.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +import 'package:acter/common/models/types.dart'; +import 'package:acter/common/widgets/video_dialog.dart'; +import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class VideoMessageEvent extends ConsumerWidget { + final String roomId; + final String messageId; + final String? eventId; + final MsgContent content; + + const VideoMessageEvent({ + super.key, + required this.roomId, + required this.messageId, + required this.content, + this.eventId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ChatMessageInfo messageInfo = + (messageId: eventId ?? messageId, roomId: roomId); + final mediaState = ref.watch(mediaChatStateProvider(messageInfo)); + if (mediaState.mediaChatLoadingState.isLoading || + mediaState.isDownloading) { + return loadingIndication(context); + } + final mediaFile = mediaState.mediaFile; + if (mediaFile == null) { + return videoPlaceholder(context, roomId, ref); + } else { + return videoUI(context, mediaFile, mediaState.videoThumbnailFile); + } + } + + Widget loadingIndication(BuildContext context) { + return const SizedBox( + width: 150, + height: 150, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + Widget videoPlaceholder(BuildContext context, String roomId, WidgetRef ref) { + final msgSize = content.size(); + if (msgSize == null) return const SizedBox.shrink(); + return InkWell( + onTap: () async { + final notifier = ref.read( + mediaChatStateProvider( + (messageId: eventId ?? messageId, roomId: roomId), + ).notifier, + ); + await notifier.downloadMedia(); + }, + child: SizedBox( + width: 200, + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.download, + size: 28, + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.video_library_rounded, + size: 18, + ), + const SizedBox(width: 5), + Text( + formatBytes(msgSize.truncate()), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget videoUI(BuildContext context, File mediaFile, File? thumbFile) { + return InkWell( + onTap: () { + showAdaptiveDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) => VideoDialog( + title: content.body(), + videoFile: mediaFile, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 300, + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (thumbFile != null) videoThumbFileView(context, thumbFile), + Container( + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(30.0), + ), + child: Icon( + Icons.play_arrow, + size: 50.0, + semanticLabel: L10n.of(context).play, + ), + ), + ], + ), + ), + ), + ); + } + + Widget videoThumbFileView(BuildContext context, File thumbFile) { + return Image.file( + thumbFile, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) { + return child; + } + return AnimatedOpacity( + opacity: frame == null ? 0 : 1, + duration: const Duration(seconds: 1), + curve: Curves.easeOut, + child: child, + ); + }, + errorBuilder: (context, url, error) { + return Text(L10n.of(context).couldNotLoadImage(error.toString())); + }, + fit: BoxFit.cover, + ); + } +} From 065fc56cceaf838fddb65795c23686f52f83cefd Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 3 Dec 2024 14:10:13 +0500 Subject: [PATCH 03/15] support pagination and simple state events, fix autoscrolling --- .../chat_room_messages_provider.dart | 20 ++++ .../chat_ng/widgets/chat_messages.dart | 96 +++++++++++++++---- .../chat_ng/widgets/events/chat_event.dart | 39 ++++++-- .../widgets/events/image_message_event.dart | 41 ++++---- .../widgets/events/member_update_event.dart | 61 ++++++++++++ .../widgets/events/state_update_event.dart | 37 +++++++ .../widgets/events/video_message_event.dart | 71 +++++++------- 7 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/events/member_update_event.dart create mode 100644 app/lib/features/chat_ng/widgets/events/state_update_event.dart diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index a530ebf3b6e1..2ace6158bea0 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -15,6 +15,26 @@ const _supportedTypes = [ 'm.room.message', 'm.room.redaction', 'm.room.encrypted', + 'm.policy.rule.room', + 'm.policy.rule.server', + 'm.policy.rule.user', + 'm.room.aliases', + 'm.room.avatar', + 'm.room.canonical_alias', + 'm.room.create', + 'm.room.encryption', + 'm.room.guest.access', + 'm.room.history_visibility', + 'm.room.join.rules', + 'm.room.name', + 'm.room.pinned_events', + 'm.room.power_levels', + 'm.room.server_acl', + 'm.room.third_party_invite', + 'm.room.tombstone', + 'm.room.topic', + 'm.space.child', + 'm.space.parent', ]; typedef RoomMsgId = (String roomId, String uniqueId); diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index 0abbc9502177..41d3ec675175 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -1,28 +1,92 @@ import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/chat_ng/widgets/events/chat_event.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class ChatMessages extends ConsumerWidget { +class ChatMessages extends ConsumerStatefulWidget { final String roomId; const ChatMessages({super.key, required this.roomId}); @override - Widget build(BuildContext context, WidgetRef ref) { - final messages = ref - .watch(chatStateProvider(roomId).select((value) => value.messageList)); - final animatedListKey = ref.watch(animatedListChatMessagesProvider(roomId)); - - return AnimatedList( - initialItemCount: messages.length, - key: animatedListKey, - itemBuilder: (_, index, animation) => Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: ChatEvent( - roomId: roomId, - eventId: messages[index], + ConsumerState createState() => _ChatMessagesConsumerState(); +} + +class _ChatMessagesConsumerState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + bool isLoading = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollToBottom(); + }); + } + + Future onScroll() async { + if (isLoading) return; + + // Check if we're near the top of the list + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent) { + setState(() => isLoading = true); + + // Get the notifier to load more messages + final notifier = ref.read(chatStateProvider(widget.roomId).notifier); + await notifier.loadMore(); + + setState(() => isLoading = false); + } + } + + void scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final messages = ref.watch( + chatStateProvider(widget.roomId).select((value) => value.messageList), + ); + final animatedListKey = + ref.watch(animatedListChatMessagesProvider(widget.roomId)); + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: isLoading ? 14 : 0, + width: isLoading ? 14 : 0, + child: Center( + child: isLoading ? const CircularProgressIndicator() : null, + ), + ), + ), + // Messages list takes remaining space + Expanded( + child: AnimatedList( + initialItemCount: messages.length, + key: animatedListKey, + controller: _scrollController, + reverse: true, + itemBuilder: (_, index, animation) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: ChatEvent( + roomId: widget.roomId, + eventId: messages[messages.length - 1 - index], + ), + ), + ), ), - ), + ], ); } } diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index 1afae640fddf..ab6a898356f8 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -6,11 +6,14 @@ import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dar import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/member_update_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/state_update_event.dart'; import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomMessage, RoomVirtualItem, RoomEventItem; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -71,6 +74,7 @@ class ChatEvent extends ConsumerWidget { ); final options = AvatarOptions.DM(avatarInfo, size: 14); final myId = ref.watch(myUserIdStrProvider); + final messageId = msg.uniqueId(); final isUser = myId == item.sender(); // TODO: render a regular timeline event return Row( @@ -86,14 +90,14 @@ class ChatEvent extends ConsumerWidget { ) : const SizedBox(width: 40), Flexible( - child: _buildEventItem(msg, item, isUser, showAvatar), + child: _buildEventItem(messageId, item, isUser, showAvatar), ), ], ); } Widget _buildEventItem( - RoomMessage msg, + String messageId, RoomEventItem item, bool isUser, bool showAvatar, @@ -101,7 +105,7 @@ class ChatEvent extends ConsumerWidget { final eventType = item.eventType(); switch (eventType) { case 'm.room.message': - return _buildMsgEventItem(roomId, item, msg, isUser, showAvatar); + return _buildMsgEventItem(roomId, messageId, item, isUser, showAvatar); case 'm.room.redaction': return ChatBubble( isUser: isUser, @@ -114,6 +118,29 @@ class ChatEvent extends ConsumerWidget { showAvatar: showAvatar, child: EncryptedMessageWidget(), ); + case 'm.room.member': + return MemberUpdateEvent(isUser: isUser, item: item); + case 'm.policy.rule.room': + case 'm.policy.rule.server': + case 'm.policy.rule.user': + case 'm.room.aliases': + case 'm.room.avatar': + case 'm.room.canonical_alias': + case 'm.room.create': + case 'm.room.encryption': + case 'm.room.guest.access': + case 'm.room.history_visibility': + case 'm.room.join.rules': + case 'm.room.name': + case 'm.room.pinned_events': + case 'm.room.power_levels': + case 'm.room.server_acl': + case 'm.room.third_party_invite': + case 'm.room.tombstone': + case 'm.room.topic': + case 'm.space.child': + case 'm.space.parent': + return StateUpdateEvent(item: item); default: return _buildUnsupportedMessage(eventType); } @@ -121,14 +148,14 @@ class ChatEvent extends ConsumerWidget { Widget _buildMsgEventItem( String roomId, + String messageId, RoomEventItem item, - RoomMessage msg, bool isUser, bool showAvatar, ) { final msgType = item.msgType(); final content = item.msgContent(); - final messageId = msg.uniqueId(); + // shouldn't happen but in case return empty if (msgType == null || content == null) return const SizedBox.shrink(); diff --git a/app/lib/features/chat_ng/widgets/events/image_message_event.dart b/app/lib/features/chat_ng/widgets/events/image_message_event.dart index 91dd092784a7..5823cc00c6c9 100644 --- a/app/lib/features/chat_ng/widgets/events/image_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/image_message_event.dart @@ -111,26 +111,29 @@ class ImageMessageEvent extends ConsumerWidget { } Widget imageUI(BuildContext context, WidgetRef ref, File mediaFile) { - return InkWell( - onTap: () { - showAdaptiveDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (context) => ImageDialog( - title: content.body(), - imageFile: mediaFile, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 300, - maxHeight: 300, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: InkWell( + onTap: () { + showAdaptiveDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) => ImageDialog( + title: content.body(), + imageFile: mediaFile, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 300, + maxHeight: 300, + ), + child: imageFileView(context, ref, mediaFile), ), - child: imageFileView(context, ref, mediaFile), ), ), ); diff --git a/app/lib/features/chat_ng/widgets/events/member_update_event.dart b/app/lib/features/chat_ng/widgets/events/member_update_event.dart new file mode 100644 index 000000000000..f3f90c862f0a --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/member_update_event.dart @@ -0,0 +1,61 @@ +import 'package:acter/common/utils/utils.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class MemberUpdateEvent extends StatelessWidget { + final bool isUser; + final RoomEventItem item; + const MemberUpdateEvent({ + super.key, + required this.isUser, + required this.item, + }); + + @override + Widget build(BuildContext context) { + final lang = L10n.of(context); + late String textMsg; + final msgType = item.eventType(); + final firstName = simplifyUserId(item.sender()); + if (msgType == 'Joined') { + if (isUser) { + textMsg = lang.chatYouJoined; + } else if (firstName != null) { + textMsg = lang.chatJoinedDisplayName(firstName); + } else { + textMsg = lang.chatJoinedUserId(item.sender()); + } + } else if (msgType == 'InvitationAccepted') { + if (isUser) { + textMsg = lang.chatYouAcceptedInvite; + } else if (firstName != null) { + textMsg = lang.chatInvitationAcceptedDisplayName(firstName); + } else { + textMsg = lang.chatInvitationAcceptedUserId(item.sender()); + } + } else if (msgType == 'Invited') { + if (isUser) { + textMsg = lang.chatYouInvited; + } else if (firstName != null) { + textMsg = lang.chatInvitedDisplayName(firstName); + } else { + textMsg = lang.chatInvitedUserId(item.sender()); + } + } else { + textMsg = item.msgContent()?.body() ?? ''; + } + return Container( + padding: const EdgeInsets.only( + left: 10, + bottom: 5, + ), + child: RichText( + text: TextSpan( + text: textMsg, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/state_update_event.dart b/app/lib/features/chat_ng/widgets/events/state_update_event.dart new file mode 100644 index 000000000000..fd8c545d9ef9 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/state_update_event.dart @@ -0,0 +1,37 @@ +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; + +class StateUpdateEvent extends StatelessWidget { + final RoomEventItem item; + const StateUpdateEvent({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final authorId = item.sender(); + final messageBody = item.msgContent()?.body() ?? ''; + + return Container( + padding: const EdgeInsets.only( + left: 10, + bottom: 5, + ), + child: RichText( + text: TextSpan( + text: authorId, + style: textTheme.bodySmall, + children: [ + const WidgetSpan( + child: SizedBox(width: 3), + ), + TextSpan( + text: messageBody, + style: textTheme.labelLarge, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/video_message_event.dart b/app/lib/features/chat_ng/widgets/events/video_message_event.dart index 839a71be6c1e..11f66ac6d83f 100644 --- a/app/lib/features/chat_ng/widgets/events/video_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/video_message_event.dart @@ -105,41 +105,44 @@ class VideoMessageEvent extends ConsumerWidget { } Widget videoUI(BuildContext context, File mediaFile, File? thumbFile) { - return InkWell( - onTap: () { - showAdaptiveDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (context) => VideoDialog( - title: content.body(), - videoFile: mediaFile, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 300, - maxHeight: 300, - ), - child: Stack( - alignment: Alignment.center, - children: [ - if (thumbFile != null) videoThumbFileView(context, thumbFile), - Container( - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(30.0), - ), - child: Icon( - Icons.play_arrow, - size: 50.0, - semanticLabel: L10n.of(context).play, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: InkWell( + onTap: () { + showAdaptiveDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) => VideoDialog( + title: content.body(), + videoFile: mediaFile, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 300, + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (thumbFile != null) videoThumbFileView(context, thumbFile), + Container( + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(30.0), + ), + child: Icon( + Icons.play_arrow, + size: 50.0, + semanticLabel: L10n.of(context).play, + ), ), - ), - ], + ], + ), ), ), ), From 5f27b096862e74f71eedeeb0db646e018cadcb00 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 3 Dec 2024 16:28:35 +0500 Subject: [PATCH 04/15] fetch correct event type for member events --- app/ios/Podfile.lock | 2 +- .../chat_ng/widgets/events/chat_event.dart | 6 +++--- .../widgets/events/file_message_event.dart | 2 +- .../widgets/events/image_message_event.dart | 2 +- .../widgets/events/member_update_event.dart | 17 +++++++++++------ .../widgets/events/state_update_event.dart | 1 + .../widgets/events/text_message_event.dart | 2 +- .../widgets/events/video_message_event.dart | 2 +- 8 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index ce071d7d8c0c..1039ffcba070 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -318,4 +318,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index ab6a898356f8..4a89761dcdf2 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -128,9 +128,9 @@ class ChatEvent extends ConsumerWidget { case 'm.room.canonical_alias': case 'm.room.create': case 'm.room.encryption': - case 'm.room.guest.access': + case 'm.room.guest_access': case 'm.room.history_visibility': - case 'm.room.join.rules': + case 'm.room.join_rules': case 'm.room.name': case 'm.room.pinned_events': case 'm.room.power_levels': @@ -193,7 +193,7 @@ class ChatEvent extends ConsumerWidget { Widget _buildUnsupportedMessage(String? msgtype) { return Text( - 'Unsupported message type: $msgtype', + 'Unsupported event type: $msgtype', ); } } diff --git a/app/lib/features/chat_ng/widgets/events/file_message_event.dart b/app/lib/features/chat_ng/widgets/events/file_message_event.dart index 564a1dff1edc..3a0760c7ec1b 100644 --- a/app/lib/features/chat_ng/widgets/events/file_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/file_message_event.dart @@ -2,7 +2,7 @@ import 'package:acter/common/models/types.dart'; import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; import 'package:acter/features/files/actions/file_share.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgContent; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; diff --git a/app/lib/features/chat_ng/widgets/events/image_message_event.dart b/app/lib/features/chat_ng/widgets/events/image_message_event.dart index 5823cc00c6c9..45fce99bb54c 100644 --- a/app/lib/features/chat_ng/widgets/events/image_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/image_message_event.dart @@ -5,7 +5,7 @@ import 'package:acter/common/toolkit/errors/inline_error_button.dart'; import 'package:acter/common/widgets/image_dialog.dart'; import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgContent; import 'package:flutter/material.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; diff --git a/app/lib/features/chat_ng/widgets/events/member_update_event.dart b/app/lib/features/chat_ng/widgets/events/member_update_event.dart index f3f90c862f0a..8c4474df4321 100644 --- a/app/lib/features/chat_ng/widgets/events/member_update_event.dart +++ b/app/lib/features/chat_ng/widgets/events/member_update_event.dart @@ -1,5 +1,6 @@ import 'package:acter/common/utils/utils.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -16,15 +17,17 @@ class MemberUpdateEvent extends StatelessWidget { Widget build(BuildContext context) { final lang = L10n.of(context); late String textMsg; - final msgType = item.eventType(); - final firstName = simplifyUserId(item.sender()); + final senderId = item.sender(); + final msgType = item.msgType(); + final firstName = simplifyUserId(senderId); + if (msgType == 'Joined') { if (isUser) { textMsg = lang.chatYouJoined; } else if (firstName != null) { textMsg = lang.chatJoinedDisplayName(firstName); } else { - textMsg = lang.chatJoinedUserId(item.sender()); + textMsg = lang.chatJoinedUserId(senderId); } } else if (msgType == 'InvitationAccepted') { if (isUser) { @@ -32,7 +35,7 @@ class MemberUpdateEvent extends StatelessWidget { } else if (firstName != null) { textMsg = lang.chatInvitationAcceptedDisplayName(firstName); } else { - textMsg = lang.chatInvitationAcceptedUserId(item.sender()); + textMsg = lang.chatInvitationAcceptedUserId(senderId); } } else if (msgType == 'Invited') { if (isUser) { @@ -40,15 +43,17 @@ class MemberUpdateEvent extends StatelessWidget { } else if (firstName != null) { textMsg = lang.chatInvitedDisplayName(firstName); } else { - textMsg = lang.chatInvitedUserId(item.sender()); + textMsg = lang.chatInvitedUserId(senderId); } } else { textMsg = item.msgContent()?.body() ?? ''; } + return Container( padding: const EdgeInsets.only( left: 10, bottom: 5, + right: 10, ), child: RichText( text: TextSpan( diff --git a/app/lib/features/chat_ng/widgets/events/state_update_event.dart b/app/lib/features/chat_ng/widgets/events/state_update_event.dart index fd8c545d9ef9..d33c39256353 100644 --- a/app/lib/features/chat_ng/widgets/events/state_update_event.dart +++ b/app/lib/features/chat_ng/widgets/events/state_update_event.dart @@ -16,6 +16,7 @@ class StateUpdateEvent extends StatelessWidget { padding: const EdgeInsets.only( left: 10, bottom: 5, + right: 10, ), child: RichText( text: TextSpan( diff --git a/app/lib/features/chat_ng/widgets/events/text_message_event.dart b/app/lib/features/chat_ng/widgets/events/text_message_event.dart index f5cfa5db2f81..4b6b6c661aa0 100644 --- a/app/lib/features/chat_ng/widgets/events/text_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/text_message_event.dart @@ -1,6 +1,6 @@ import 'package:acter/features/chat/widgets/pill_builder.dart'; import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgContent; import 'package:flutter/material.dart'; import 'package:flutter_matrix_html/flutter_html.dart'; import 'package:flutter_matrix_html/text_parser.dart'; diff --git a/app/lib/features/chat_ng/widgets/events/video_message_event.dart b/app/lib/features/chat_ng/widgets/events/video_message_event.dart index 11f66ac6d83f..03ca76a29c25 100644 --- a/app/lib/features/chat_ng/widgets/events/video_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/video_message_event.dart @@ -4,7 +4,7 @@ import 'package:acter/common/models/types.dart'; import 'package:acter/common/widgets/video_dialog.dart'; import 'package:acter/features/chat/models/media_chat_state/media_chat_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgContent; import 'package:flutter/material.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; From ee053795ff2c0be353eeab9aacfbcd203d8428dd Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 4 Dec 2024 15:56:09 +0500 Subject: [PATCH 05/15] provider unit test for avatar messages --- .../features/chat_ng/diff_applier_test.dart | 14 +++- .../chat_ng/messages/chat_message_test.dart | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 app/test/features/chat_ng/messages/chat_message_test.dart diff --git a/app/test/features/chat_ng/diff_applier_test.dart b/app/test/features/chat_ng/diff_applier_test.dart index 866240d37634..ce92e56d7bd5 100644 --- a/app/test/features/chat_ng/diff_applier_test.dart +++ b/app/test/features/chat_ng/diff_applier_test.dart @@ -5,6 +5,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'messages/chat_message_test.dart'; + class MockedRoomMessageDiff extends Mock implements RoomMessageDiff { final String act; final int? idx; @@ -44,11 +46,15 @@ class MockFfiListRoomMessage extends Mock implements FfiListRoomMessage { class MockRoomMessage extends Mock implements RoomMessage { final String id; + final MockRoomEventItem? mockEventItem; - MockRoomMessage({required this.id}); + MockRoomMessage({required this.id, this.mockEventItem}); @override String uniqueId() => id; + + @override + MockRoomEventItem? eventItem() => mockEventItem; } class MockAnimatedListState extends Mock implements AnimatedListState { @@ -671,7 +677,8 @@ void main() { expect(newState.messages.keys, ['b', 'd', 'e', 'f', 'g', 'h']); final verifier1 = verify( - () => mockAnimatedState.insertAllItems(captureAny(), captureAny()),); + () => mockAnimatedState.insertAllItems(captureAny(), captureAny()), + ); verifier1.called(1); expect(verifier1.captured, [4, 2]); @@ -691,7 +698,8 @@ void main() { expect(secondState.messages.keys, ['b', 'd', 'e', 'f', 'a']); final verifier2 = verify( - () => mockAnimatedState.insertAllItems(captureAny(), captureAny()),); + () => mockAnimatedState.insertAllItems(captureAny(), captureAny()), + ); verifier2.called(1); expect(verifier2.captured, [4, 1]); }); diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart new file mode 100644 index 000000000000..c9c34479f141 --- /dev/null +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -0,0 +1,73 @@ +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../diff_applier_test.dart'; + +class MockRoomEventItem extends Mock implements RoomEventItem { + final String mockSender; + + MockRoomEventItem({required this.mockSender}); + @override + String sender() => mockSender; +} + +void main() { + group('Chat-NG messages test', () { + group('show avatars provider tests ', () { + final userMsgA1 = MockRoomMessage( + id: 'A1', + mockEventItem: MockRoomEventItem(mockSender: 'user-1'), + ); + final userMsgA2 = MockRoomMessage( + id: 'A2', + mockEventItem: MockRoomEventItem(mockSender: 'user-1'), + ); + final userMsgB1 = MockRoomMessage( + id: 'B1', + mockEventItem: MockRoomEventItem(mockSender: 'user-2'), + ); + + final container = ProviderContainer( + overrides: [ + renderableChatMessagesProvider + .overrideWith((ref, roomId) => ['A1', 'A2', 'B1']), + chatRoomMessageProvider.overrideWith((ref, roomMsgId) { + final uniqueId = roomMsgId.$2; + switch (uniqueId) { + case 'A1': + return userMsgA1; + case 'A2': + return userMsgA2; + case 'B1': + return userMsgB1; + default: + return null; + } + }), + ], + ); + + test('shows avatar for last message in the list', () { + final RoomMsgId query = ('test-room', 'B1'); + final result = container.read(shouldShowAvatarProvider(query)); + expect(result, true); + }); + + test('shows avatar when next message is from different user', () { + final RoomMsgId query = ('test-room', 'A2'); + final result = container.read(shouldShowAvatarProvider(query)); + expect(result, true); + }); + + test('hides avatar when next message is from same user', () { + final RoomMsgId query = ('test-room', 'A1'); + final result = container.read(shouldShowAvatarProvider(query)); + expect(result, false); + }); + }); + }); +} From 83997486d4da74f63bdd8c3786d52b0f3c2978c7 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 4 Dec 2024 16:13:24 +0500 Subject: [PATCH 06/15] chat-ng image message event test --- .../chat_ng/messages/chat_message_test.dart | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index c9c34479f141..a13e920fb5e6 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -1,10 +1,20 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/toolkit/errors/inline_error_button.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart' as chat; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; +import 'package:convenient_test_dev/convenient_test_dev.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../helpers/error_helpers.dart'; +import '../../../helpers/mock_chat_providers.dart'; +import '../../../helpers/test_util.dart'; +import '../../chat/messages/image_message_test.dart'; +import '../../comments/mock_data/mock_message_content.dart'; import '../diff_applier_test.dart'; class MockRoomEventItem extends Mock implements RoomEventItem { @@ -69,5 +79,30 @@ void main() { expect(result, false); }); }); + + group('Image Messages Test', () { + testWidgets('shows errors and retries', (tester) async { + final content = MockMsgContent(bodyText: 'msgContent.body()'); + await tester.pumpProviderWidget( + overrides: [ + // Provider first provides a broken path to trigger the error + // then null, so it would check for auto-download but not attempt + chatProvider.overrideWith( + () => MockAsyncConvoNotifier(retVal: RetryMediaConvoMock()), + ), + chat.autoDownloadMediaProvider.overrideWith((a, b) => false), + ], + child: ImageMessageEvent( + messageId: 'eventId', + roomId: '!roomId', + content: content, + ), + ); + await tester.pumpWithRunAsyncUntil( + () => findsOne.matches(find.byType(ActerInlineErrorButton), {}), + ); + await tester.ensureInlineErrorWithRetryWorks(); + }); + }); }); } From 8ddd2f328b89579fa6e5827050fd461c39f1ab98 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 4 Dec 2024 17:41:17 +0500 Subject: [PATCH 07/15] add changelogs --- .changes/2388-chat-ng-list-UI.md | 6 ++++++ app/ios/Podfile.lock | 2 +- app/macos/Podfile.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changes/2388-chat-ng-list-UI.md diff --git a/.changes/2388-chat-ng-list-UI.md b/.changes/2388-chat-ng-list-UI.md new file mode 100644 index 000000000000..31f7f1da10cf --- /dev/null +++ b/.changes/2388-chat-ng-list-UI.md @@ -0,0 +1,6 @@ +- [Labs] Chat-NG: + - supports new UI message bubbles with initial message types support .i.e. text, image, video. + - messages list now also supports initial added state events .i.e. member, redacted, encrypted. + - pagination to load more messages upon scrolling. + - Initial scroll to new messages on entering room. + - Updated test coverage. diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 1039ffcba070..ce071d7d8c0c 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -318,4 +318,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index 9a9ab14c8faa..d4482b3c4a46 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -208,4 +208,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: dac0ddf03d136db544afc27b87cc6c08492e67b9 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 From c355dddd835f383d99ade49f164b4681479f5fe9 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 4 Dec 2024 17:43:00 +0500 Subject: [PATCH 08/15] revert macos podfile changes --- app/macos/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index d4482b3c4a46..9a9ab14c8faa 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -208,4 +208,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: dac0ddf03d136db544afc27b87cc6c08492e67b9 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 From e3e9df4c918134a9769478d0637348ec51c1b922 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 13:58:09 +0500 Subject: [PATCH 09/15] Feedback Review: Code improvements --- .../chat_room_messages_provider.dart | 22 +-- .../features/chat_ng/widgets/chat_bubble.dart | 101 +++++++++---- .../chat_ng/widgets/chat_messages.dart | 10 +- .../chat_ng/widgets/events/chat_event.dart | 129 ++--------------- .../widgets/events/chat_event_item.dart | 136 ++++++++++++++++++ .../widgets/events/text_message_event.dart | 32 ++++- .../chat_ng/messages/chat_message_test.dart | 6 +- 7 files changed, 264 insertions(+), 172 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/events/chat_event_item.dart diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 2ace6158bea0..8b2860f64372 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -15,26 +15,6 @@ const _supportedTypes = [ 'm.room.message', 'm.room.redaction', 'm.room.encrypted', - 'm.policy.rule.room', - 'm.policy.rule.server', - 'm.policy.rule.user', - 'm.room.aliases', - 'm.room.avatar', - 'm.room.canonical_alias', - 'm.room.create', - 'm.room.encryption', - 'm.room.guest.access', - 'm.room.history_visibility', - 'm.room.join.rules', - 'm.room.name', - 'm.room.pinned_events', - 'm.room.power_levels', - 'm.room.server_acl', - 'm.room.third_party_invite', - 'm.room.tombstone', - 'm.room.topic', - 'm.space.child', - 'm.space.parent', ]; typedef RoomMsgId = (String roomId, String uniqueId); @@ -80,7 +60,7 @@ final renderableChatMessagesProvider = }); // Provider to check if we should show avatar by comparing with the next message -final shouldShowAvatarProvider = Provider.family( +final isNextMessageGroupProvider = Provider.family( (ref, roomMsgId) { final roomId = roomMsgId.$1; final eventId = roomMsgId.$2; diff --git a/app/lib/features/chat_ng/widgets/chat_bubble.dart b/app/lib/features/chat_ng/widgets/chat_bubble.dart index 7fc0f1d00577..8235e329550b 100644 --- a/app/lib/features/chat_ng/widgets/chat_bubble.dart +++ b/app/lib/features/chat_ng/widgets/chat_bubble.dart @@ -3,50 +3,101 @@ import 'package:flutter/material.dart'; import 'package:acter/common/extensions/options.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -// Chat Bubble UI class ChatBubble extends StatelessWidget { - final int? messageWidth; - // inner content final Widget child; - final bool isUser; - final bool showAvatar; + final int? messageWidth; final bool wasEdited; + final bool nextMessageGroup; + final BoxDecoration decoration; + final CrossAxisAlignment bubbleAlignment; - const ChatBubble({ + // default private constructor + const ChatBubble._inner({ super.key, required this.child, + required this.wasEdited, + required this.bubbleAlignment, + required this.decoration, this.messageWidth, - this.isUser = false, - this.showAvatar = true, - this.wasEdited = false, + this.nextMessageGroup = false, }); + // factory bubble constructor + factory ChatBubble({ + required Widget child, + required BuildContext context, + int? messageWidth, + bool wasEdited = false, + bool nextMessageGroup = false, + }) { + final theme = Theme.of(context); + return ChatBubble._inner( + wasEdited: wasEdited, + messageWidth: messageWidth, + nextMessageGroup: nextMessageGroup, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(nextMessageGroup ? 16 : 4), + bottomRight: Radius.circular(16), + ), + ), + bubbleAlignment: CrossAxisAlignment.start, + child: child, + ); + } + + // for user's own messages + factory ChatBubble.user({ + Key? key, + required BuildContext context, + required Widget child, + int? messageWidth, + bool wasEdited = false, + bool nextMessageGroup = false, + }) { + final theme = Theme.of(context); + return ChatBubble._inner( + key: key, + messageWidth: messageWidth, + wasEdited: wasEdited, + nextMessageGroup: nextMessageGroup, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(nextMessageGroup ? 16 : 4), + ), + ), + bubbleAlignment: CrossAxisAlignment.end, + child: DefaultTextStyle.merge( + style: TextStyle(color: theme.colorScheme.onPrimary), + child: child, + ), + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); final chatTheme = Theme.of(context).chatTheme; final size = MediaQuery.sizeOf(context); final msgWidth = messageWidth.map((w) => w.toDouble()); + return Container( - margin: EdgeInsets.symmetric(horizontal: 8), + margin: const EdgeInsets.symmetric(horizontal: 8), child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: bubbleAlignment, children: [ Container( - constraints: BoxConstraints(maxWidth: msgWidth ?? size.width), - width: msgWidth, - decoration: BoxDecoration( - color: isUser - ? theme.colorScheme.primary - : theme.colorScheme.surface, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: Radius.circular(isUser || !showAvatar ? 16 : 4), - bottomRight: Radius.circular(isUser ? 4 : 16), - ), + constraints: BoxConstraints( + maxWidth: msgWidth ?? size.width, ), + width: msgWidth, + decoration: decoration, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index 41d3ec675175..b864c142bdcd 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -1,3 +1,4 @@ +import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/chat_ng/widgets/events/chat_event.dart'; import 'package:flutter/material.dart'; @@ -13,7 +14,10 @@ class ChatMessages extends ConsumerStatefulWidget { class _ChatMessagesConsumerState extends ConsumerState { final ScrollController _scrollController = ScrollController(); - bool isLoading = false; + + bool get isLoading => ref.watch( + chatStateProvider(widget.roomId).select((v) => v.loading.isLoading), + ); @override void initState() { @@ -30,13 +34,11 @@ class _ChatMessagesConsumerState extends ConsumerState { // Check if we're near the top of the list if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent) { - setState(() => isLoading = true); + if (isLoading) return; // Get the notifier to load more messages final notifier = ref.read(chatStateProvider(widget.roomId).notifier); await notifier.loadMore(); - - setState(() => isLoading = false); } } diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index 4a89761dcdf2..8ed748cee21e 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -1,15 +1,7 @@ import 'package:acter/common/providers/common_providers.dart'; import 'package:acter/common/providers/room_providers.dart'; -import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; -import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; -import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; -import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/member_update_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/state_update_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/chat_event_item.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' @@ -35,7 +27,7 @@ class ChatEvent extends ConsumerWidget { if (msg == null) { _log.severe('Msg not found $roomId $eventId'); - return const SizedBox.shrink(); + return ErrorWidget('Msg not found $roomId $eventId'); } final inner = msg.eventItem(); @@ -50,7 +42,7 @@ class ChatEvent extends ConsumerWidget { return renderVirtual(msg, virtual); } - return renderEvent(msg: msg, item: inner, ref: ref); + return renderEvent(ctx: context, msg: msg, item: inner, ref: ref); } Widget renderVirtual(RoomMessage msg, RoomVirtualItem virtual) { @@ -59,11 +51,13 @@ class ChatEvent extends ConsumerWidget { } Widget renderEvent({ + required BuildContext ctx, required RoomMessage msg, required RoomEventItem item, required WidgetRef ref, }) { - final showAvatar = ref.watch(shouldShowAvatarProvider((roomId, eventId))); + final nextMessageGroup = + ref.watch(isNextMessageGroupProvider((roomId, eventId))); final avatarInfo = ref.watch( memberAvatarInfoProvider( ( @@ -83,117 +77,22 @@ class ChatEvent extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - (showAvatar && !isUser) + (nextMessageGroup && !isUser) ? Padding( padding: const EdgeInsets.only(left: 8), child: ActerAvatar(options: options), ) : const SizedBox(width: 40), Flexible( - child: _buildEventItem(messageId, item, isUser, showAvatar), + child: ChatEventItem( + roomId: roomId, + messageId: messageId, + item: item, + isUser: isUser, + nextMessageGroup: nextMessageGroup, + ), ), ], ); } - - Widget _buildEventItem( - String messageId, - RoomEventItem item, - bool isUser, - bool showAvatar, - ) { - final eventType = item.eventType(); - switch (eventType) { - case 'm.room.message': - return _buildMsgEventItem(roomId, messageId, item, isUser, showAvatar); - case 'm.room.redaction': - return ChatBubble( - isUser: isUser, - showAvatar: showAvatar, - child: RedactedMessageWidget(), - ); - case 'm.room.encrypted': - return ChatBubble( - isUser: isUser, - showAvatar: showAvatar, - child: EncryptedMessageWidget(), - ); - case 'm.room.member': - return MemberUpdateEvent(isUser: isUser, item: item); - case 'm.policy.rule.room': - case 'm.policy.rule.server': - case 'm.policy.rule.user': - case 'm.room.aliases': - case 'm.room.avatar': - case 'm.room.canonical_alias': - case 'm.room.create': - case 'm.room.encryption': - case 'm.room.guest_access': - case 'm.room.history_visibility': - case 'm.room.join_rules': - case 'm.room.name': - case 'm.room.pinned_events': - case 'm.room.power_levels': - case 'm.room.server_acl': - case 'm.room.third_party_invite': - case 'm.room.tombstone': - case 'm.room.topic': - case 'm.space.child': - case 'm.space.parent': - return StateUpdateEvent(item: item); - default: - return _buildUnsupportedMessage(eventType); - } - } - - Widget _buildMsgEventItem( - String roomId, - String messageId, - RoomEventItem item, - bool isUser, - bool showAvatar, - ) { - final msgType = item.msgType(); - final content = item.msgContent(); - - // shouldn't happen but in case return empty - if (msgType == null || content == null) return const SizedBox.shrink(); - - switch (msgType) { - case 'm.emote': - case 'm.text': - return TextMessageEvent( - roomId: roomId, - content: content, - isUser: isUser, - showAvatar: showAvatar, - ); - case 'm.image': - return ImageMessageEvent( - messageId: messageId, - roomId: roomId, - content: content, - ); - case 'm.video': - return VideoMessageEvent( - roomId: roomId, - messageId: messageId, - content: content, - ); - case 'm.file': - return FileMessageEvent( - roomId: roomId, - messageId: messageId, - content: content, - ); - default: - return _buildUnsupportedMessage(msgType); - } - } - - Widget _buildUnsupportedMessage(String? msgtype) { - return Text( - 'Unsupported event type: $msgtype', - ); - } } diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart new file mode 100644 index 000000000000..18d1f5adc50a --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -0,0 +1,136 @@ +import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; +import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; +import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; +import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/member_update_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/state_update_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; + +class ChatEventItem extends StatelessWidget { + final String roomId; + final String messageId; + final RoomEventItem item; + final bool isUser; + final bool nextMessageGroup; + const ChatEventItem({ + super.key, + required this.roomId, + required this.messageId, + required this.item, + required this.isUser, + required this.nextMessageGroup, + }); + + @override + Widget build(BuildContext context) { + final eventType = item.eventType(); + return switch (eventType) { + // handle message inner types separately + 'm.room.message' => buildMsgEventItem( + roomId, + messageId, + item, + isUser, + nextMessageGroup, + ), + 'm.room.redaction' => isUser + ? ChatBubble.user( + context: context, + nextMessageGroup: nextMessageGroup, + child: RedactedMessageWidget(), + ) + : ChatBubble( + context: context, + nextMessageGroup: nextMessageGroup, + child: RedactedMessageWidget(), + ), + 'm.room.encrypted' => isUser + ? ChatBubble.user( + context: context, + nextMessageGroup: nextMessageGroup, + child: EncryptedMessageWidget(), + ) + : ChatBubble( + context: context, + nextMessageGroup: nextMessageGroup, + child: EncryptedMessageWidget(), + ), + 'm.room.member' => MemberUpdateEvent( + isUser: isUser, + item: item, + ), + 'm.policy.rule.room' || + 'm.policy.rule.server' || + 'm.policy.rule.user' || + 'm.room.aliases' || + 'm.room.avatar' || + 'm.room.canonical_alias' || + 'm.room.create' || + 'm.room.encryption' || + 'm.room.guest_access' || + 'm.room.history_visibility' || + 'm.room.join_rules' || + 'm.room.name' || + 'm.room.pinned_events' || + 'm.room.power_levels' || + 'm.room.server_acl' || + 'm.room.third_party_invite' || + 'm.room.tombstone' || + 'm.room.topic' || + 'm.space.child' || + 'm.space.parent' => + StateUpdateEvent(item: item), + _ => _buildUnsupportedMessage(eventType), + }; + } + + Widget buildMsgEventItem( + String roomId, + String messageId, + RoomEventItem item, + bool isUser, + bool showAvatar, + ) { + final msgType = item.msgType(); + final content = item.msgContent(); + + // shouldn't happen but in case return empty + if (msgType == null || content == null) return const SizedBox.shrink(); + + return switch (msgType) { + 'm.emote' || 'm.text' => TextMessageEvent( + roomId: roomId, + content: content, + isUser: isUser, + nextMessageGroup: showAvatar, + ), + 'm.image' => ImageMessageEvent( + messageId: messageId, + roomId: roomId, + content: content, + ), + 'm.video' => VideoMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + 'm.file' => FileMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + _ => _buildUnsupportedMessage(msgType), + }; + } + + Widget _buildUnsupportedMessage(String? msgtype) { + return Text( + 'Unsupported event type: $msgtype', + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/text_message_event.dart b/app/lib/features/chat_ng/widgets/events/text_message_event.dart index 4b6b6c661aa0..c14443f93051 100644 --- a/app/lib/features/chat_ng/widgets/events/text_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/text_message_event.dart @@ -9,14 +9,14 @@ class TextMessageEvent extends StatelessWidget { final String roomId; final MsgContent content; final bool isUser; - final bool showAvatar; + final bool nextMessageGroup; final bool wasEdited; const TextMessageEvent({ super.key, required this.content, required this.isUser, - required this.showAvatar, + required this.nextMessageGroup, required this.roomId, this.wasEdited = false, }); @@ -24,9 +24,33 @@ class TextMessageEvent extends StatelessWidget { @override Widget build(BuildContext context) { final body = content.formattedBody() ?? content.body(); + + final Widget inner = Html( + shrinkToFit: true, + pillBuilder: ({ + required String identifier, + required String url, + OnPillTap? onTap, + }) => + ActerPillBuilder( + identifier: identifier, + uri: url, + roomId: roomId, + ), + renderNewlines: true, + data: body, + ); + if (isUser) { + return ChatBubble.user( + context: context, + wasEdited: wasEdited, + nextMessageGroup: nextMessageGroup, + child: inner, + ); + } return ChatBubble( - isUser: isUser, - showAvatar: showAvatar, + context: context, + nextMessageGroup: nextMessageGroup, wasEdited: wasEdited, child: Html( shrinkToFit: true, diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index a13e920fb5e6..364a74e93dd0 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -63,19 +63,19 @@ void main() { test('shows avatar for last message in the list', () { final RoomMsgId query = ('test-room', 'B1'); - final result = container.read(shouldShowAvatarProvider(query)); + final result = container.read(isNextMessageGroupProvider(query)); expect(result, true); }); test('shows avatar when next message is from different user', () { final RoomMsgId query = ('test-room', 'A2'); - final result = container.read(shouldShowAvatarProvider(query)); + final result = container.read(isNextMessageGroupProvider(query)); expect(result, true); }); test('hides avatar when next message is from same user', () { final RoomMsgId query = ('test-room', 'A1'); - final result = container.read(shouldShowAvatarProvider(query)); + final result = container.read(isNextMessageGroupProvider(query)); expect(result, false); }); }); From 8e724798339bd2398fd5d259c0d1c0d162ccb511 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 15:37:34 +0500 Subject: [PATCH 10/15] Feedback review: use more meaningful semantic for avatar showing --- app/ios/Podfile.lock | 2 +- .../chat_ng/providers/chat_room_messages_provider.dart | 8 ++++---- app/lib/features/chat_ng/widgets/events/chat_event.dart | 4 ++-- app/test/features/chat_ng/messages/chat_message_test.dart | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index ce071d7d8c0c..1039ffcba070 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -318,4 +318,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 8b2860f64372..7ea93210c627 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -67,8 +67,8 @@ final isNextMessageGroupProvider = Provider.family( final messages = ref.watch(renderableChatMessagesProvider(roomId)); final currentIndex = messages.indexOf(eventId); - // Always show avatar for the first message (last in the list) - if (currentIndex == messages.length - 1) return true; + // Always show avatar for the first message (last in the list), so not affecting group state + if (currentIndex == messages.length - 1) return false; // Get current and next message final currentMsg = ref.watch(chatRoomMessageProvider(roomMsgId)); @@ -81,8 +81,8 @@ final isNextMessageGroupProvider = Provider.family( final currentSender = currentMsg.eventItem()?.sender(); final nextSender = nextMsg.eventItem()?.sender(); - // Show avatar if next message is from a different sender - return currentSender != nextSender; + // is next message in group from same sender + return currentSender == nextSender; }, ); diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index 8ed748cee21e..794ef3b0b5d0 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -9,7 +9,7 @@ import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; -final _log = Logger('a3::chat::widgets::room_message'); +final _log = Logger('a3::chat_ng::widgets::room_message'); class ChatEvent extends ConsumerWidget { final String roomId; @@ -77,7 +77,7 @@ class ChatEvent extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - (nextMessageGroup && !isUser) + (!nextMessageGroup && !isUser) ? Padding( padding: const EdgeInsets.only(left: 8), child: ActerAvatar(options: options), diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index 364a74e93dd0..7654ca739665 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -70,13 +70,13 @@ void main() { test('shows avatar when next message is from different user', () { final RoomMsgId query = ('test-room', 'A2'); final result = container.read(isNextMessageGroupProvider(query)); - expect(result, true); + expect(result, false); }); test('hides avatar when next message is from same user', () { final RoomMsgId query = ('test-room', 'A1'); final result = container.read(isNextMessageGroupProvider(query)); - expect(result, false); + expect(result, true); }); }); From 40b1eaa4c3bf2516e75b2dd09175fb7fc32cbb74 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 19:12:37 +0500 Subject: [PATCH 11/15] Feedback review: fix messages list indexing and add scrolling state cases --- .../chat_ng/widgets/chat_messages.dart | 114 ++++++++++++------ 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index b864c142bdcd..d8d5dbe297de 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -1,3 +1,4 @@ +import 'package:acter/features/chat/widgets/rooms_list.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/chat_ng/widgets/events/chat_event.dart'; @@ -13,7 +14,8 @@ class ChatMessages extends ConsumerStatefulWidget { } class _ChatMessagesConsumerState extends ConsumerState { - final ScrollController _scrollController = ScrollController(); + final ScrollController _scrollController = + ScrollController(keepScrollOffset: true); bool get isLoading => ref.watch( chatStateProvider(widget.roomId).select((v) => v.loading.isLoading), @@ -22,18 +24,29 @@ class _ChatMessagesConsumerState extends ConsumerState { @override void initState() { super.initState(); - _scrollController.addListener(onScroll); - WidgetsBinding.instance.addPostFrameCallback((_) { - scrollToBottom(); + // for first time messages load, should scroll at the latest (bottom) + ref.listenManual( + chatStateProvider(widget.roomId).select((value) => value.messageList), + (prev, next) { + if (prev != next && next.length <= 10) { + WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd()); + } }); + _scrollController.addListener(onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); } Future onScroll() async { if (isLoading) return; // Check if we're near the top of the list - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent) { + if (_scrollController.position.pixels <= + _scrollController.position.minScrollExtent) { if (isLoading) return; // Get the notifier to load more messages @@ -42,14 +55,12 @@ class _ChatMessagesConsumerState extends ConsumerState { } } - void scrollToBottom() { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.minScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } + void scrollToEnd() { + if (!mounted || !_scrollController.hasClients) return; + + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent, + ); } @override @@ -57,38 +68,67 @@ class _ChatMessagesConsumerState extends ConsumerState { final messages = ref.watch( chatStateProvider(widget.roomId).select((value) => value.messageList), ); + final animatedListKey = ref.watch(animatedListChatMessagesProvider(widget.roomId)); - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: isLoading ? 14 : 0, - width: isLoading ? 14 : 0, - child: Center( - child: isLoading ? const CircularProgressIndicator() : null, + + return PageStorage( + bucket: bucketGlobal, + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + _buildMessagesList(animatedListKey, messages), + _buildScrollIndicator(), + ], ), ), - ), - // Messages list takes remaining space - Expanded( - child: AnimatedList( - initialItemCount: messages.length, - key: animatedListKey, - controller: _scrollController, - reverse: true, - itemBuilder: (_, index, animation) => Padding( + ], + ), + ); + } + + Widget _buildMessagesList( + GlobalKey animatedListKey, + List messages, + ) => + KeyedSubtree( + key: PageStorageKey('chat_list_${widget.roomId}'), + child: AnimatedList( + initialItemCount: messages.length, + key: animatedListKey, + controller: _scrollController, + reverse: false, + padding: const EdgeInsets.only( + top: 40, + ), + itemBuilder: (_, index, animation) => FadeTransition( + opacity: animation, + child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: ChatEvent( roomId: widget.roomId, - eventId: messages[messages.length - 1 - index], + eventId: messages[index], ), ), ), ), - ], - ); - } + ); + + Widget _buildScrollIndicator() => Positioned( + top: 12, + left: 0, + right: 0, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: isLoading ? 14 : 0, + width: isLoading ? 14 : 0, + child: Center( + child: isLoading ? const CircularProgressIndicator() : null, + ), + ), + ), + ); } From fdaeddaef889dbfd2ccd5614487d05f3916a921f Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 20:04:19 +0500 Subject: [PATCH 12/15] fix scrolling when adding new messages --- .../chat_ng/widgets/chat_messages.dart | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index d8d5dbe297de..a0bb222ca658 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -26,9 +26,9 @@ class _ChatMessagesConsumerState extends ConsumerState { super.initState(); // for first time messages load, should scroll at the latest (bottom) ref.listenManual( - chatStateProvider(widget.roomId).select((value) => value.messageList), - (prev, next) { - if (prev != next && next.length <= 10) { + chatStateProvider(widget.roomId) + .select((value) => value.messageList.length), (prev, next) { + if (prev != next) { WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd()); } }); @@ -58,8 +58,10 @@ class _ChatMessagesConsumerState extends ConsumerState { void scrollToEnd() { if (!mounted || !_scrollController.hasClients) return; - _scrollController.jumpTo( + _scrollController.animateTo( _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, ); } @@ -69,9 +71,6 @@ class _ChatMessagesConsumerState extends ConsumerState { chatStateProvider(widget.roomId).select((value) => value.messageList), ); - final animatedListKey = - ref.watch(animatedListChatMessagesProvider(widget.roomId)); - return PageStorage( bucket: bucketGlobal, child: Column( @@ -79,7 +78,7 @@ class _ChatMessagesConsumerState extends ConsumerState { Expanded( child: Stack( children: [ - _buildMessagesList(animatedListKey, messages), + _buildMessagesList(messages), _buildScrollIndicator(), ], ), @@ -90,27 +89,23 @@ class _ChatMessagesConsumerState extends ConsumerState { } Widget _buildMessagesList( - GlobalKey animatedListKey, List messages, ) => KeyedSubtree( key: PageStorageKey('chat_list_${widget.roomId}'), child: AnimatedList( initialItemCount: messages.length, - key: animatedListKey, + key: ref.watch(animatedListChatMessagesProvider(widget.roomId)), controller: _scrollController, reverse: false, padding: const EdgeInsets.only( top: 40, ), - itemBuilder: (_, index, animation) => FadeTransition( - opacity: animation, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: ChatEvent( - roomId: widget.roomId, - eventId: messages[index], - ), + itemBuilder: (_, index, animation) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: ChatEvent( + roomId: widget.roomId, + eventId: messages[index], ), ), ), From 44acfc43655f6dfb146194c46621180f8024ee02 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 20:05:21 +0500 Subject: [PATCH 13/15] revert podfile changes --- app/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 1039ffcba070..ce071d7d8c0c 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -318,4 +318,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 From ad111075d0cce3e330add376d6ec059653dd15d0 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 5 Dec 2024 20:16:57 +0500 Subject: [PATCH 14/15] fix minor bug in scrolling --- app/lib/features/chat_ng/widgets/chat_messages.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index a0bb222ca658..2e5cfbe1fd14 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -27,8 +27,10 @@ class _ChatMessagesConsumerState extends ConsumerState { // for first time messages load, should scroll at the latest (bottom) ref.listenManual( chatStateProvider(widget.roomId) - .select((value) => value.messageList.length), (prev, next) { - if (prev != next) { + .select((value) => value.messageList.length), (_, __) { + if (_scrollController.hasClients && + _scrollController.position.pixels > + _scrollController.position.maxScrollExtent - 150) { WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd()); } }); From a21626c44a96df960bcb966dfc5a6dfeb07ec121 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 6 Dec 2024 15:18:15 +0500 Subject: [PATCH 15/15] correct tests --- app/test/features/chat_ng/messages/chat_message_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index 7654ca739665..ac97b0170b1f 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -64,7 +64,7 @@ void main() { test('shows avatar for last message in the list', () { final RoomMsgId query = ('test-room', 'B1'); final result = container.read(isNextMessageGroupProvider(query)); - expect(result, true); + expect(result, false); }); test('shows avatar when next message is from different user', () {