diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 250d8a90..dbdad01e 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -39,6 +39,7 @@ import "package:island/widgets/chat/chat_input.dart"; import "package:island/widgets/chat/chat_link_attachments.dart"; import "package:island/widgets/chat/public_room_preview.dart"; import "package:island/screens/thought/think_sheet.dart"; +import "package:island/screens/chat/widgets/message_item_wrapper.dart"; class ChatRoomScreen extends HookConsumerWidget { final String id; @@ -178,6 +179,38 @@ class ChatRoomScreen extends HookConsumerWidget { final isSelectionMode = useState(false); final selectedMessages = useState>({}); + final roomOpenTime = useMemoized(() => DateTime.now()); + + final onMessageAction = useCallback( + (String action, LocalChatMessage message) { + switch (action) { + case MessageItemAction.delete: + messagesNotifier.deleteMessage(message.id); + case MessageItemAction.edit: + messageEditingTo.value = message.toRemoteMessage(); + messageController.text = messageEditingTo.value?.content ?? ''; + attachments.value = + messageEditingTo.value!.attachments + .map((e) => UniversalFile.fromAttachment(e)) + .toList(); + case MessageItemAction.forward: + messageForwardingTo.value = message.toRemoteMessage(); + case MessageItemAction.reply: + messageReplyingTo.value = message.toRemoteMessage(); + case MessageItemAction.resend: + messagesNotifier.retryMessage(message.id); + } + }, + [ + messagesNotifier, + messageEditingTo, + messageController, + attachments, + messageForwardingTo, + messageReplyingTo, + ], + ); + var isLoading = false; var isScrollingToMessage = false; // Flag to prevent scroll conflicts @@ -627,7 +660,6 @@ class ChatRoomScreen extends HookConsumerWidget { duration: const Duration(milliseconds: 200), curve: Curves.easeOut, padding: EdgeInsets.only( - top: 16, bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value, ), child: SuperListView.builder( @@ -659,138 +691,30 @@ class ChatRoomScreen extends HookConsumerWidget { final key = Key('$messageKeyPrefix${message.nonce ?? message.id}'); - final messageWidget = chatIdentity.when( - skipError: true, - data: - (identity) => GestureDetector( - onLongPress: () { - if (!isSelectionMode.value) { - toggleSelectionMode(); - toggleMessageSelection(message.id); - } - }, - onTap: () { - if (isSelectionMode.value) { - toggleMessageSelection(message.id); - } - }, - child: Container( - color: - selectedMessages.value.contains(message.id) - ? Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.3) - : null, - child: Stack( - children: [ - MessageItem( - key: settings.disableAnimation ? key : null, - message: message, - isCurrentUser: identity?.id == message.senderId, - onAction: - isSelectionMode.value - ? null - : (action) { - switch (action) { - case MessageItemAction.delete: - messagesNotifier.deleteMessage( - message.id, - ); - case MessageItemAction.edit: - messageEditingTo.value = - message.toRemoteMessage(); - messageController.text = - messageEditingTo.value?.content ?? - ''; - attachments.value = - messageEditingTo.value!.attachments - .map( - (e) => - UniversalFile.fromAttachment( - e, - ), - ) - .toList(); - case MessageItemAction.forward: - messageForwardingTo.value = - message.toRemoteMessage(); - case MessageItemAction.reply: - messageReplyingTo.value = - message.toRemoteMessage(); - case MessageItemAction.resend: - messagesNotifier.retryMessage( - message.id, - ); - } - }, - onJump: - (messageId) => scrollToMessage( - messageId: messageId, - messageList: messageList, - messagesNotifier: messagesNotifier, - listController: listController, - scrollController: scrollController, - ref: ref, - ), - progress: attachmentProgress.value[message.id], - showAvatar: isLastInGroup, - isSelectionMode: isSelectionMode.value, - isSelected: selectedMessages.value.contains( - message.id, - ), - onToggleSelection: toggleMessageSelection, - onEnterSelectionMode: () { - if (!isSelectionMode.value) toggleSelectionMode(); - }, - ), - if (selectedMessages.value.contains(message.id)) - Positioned( - top: 8, - right: 8, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - size: 12, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - ), - ], - ), - ), + return MessageItemWrapper( + key: key, + message: message, + index: index, + isLastInGroup: isLastInGroup, + isSelectionMode: isSelectionMode.value, + selectedMessages: selectedMessages.value, + chatIdentity: chatIdentity, + toggleSelectionMode: toggleSelectionMode, + toggleMessageSelection: toggleMessageSelection, + onMessageAction: onMessageAction, + onJump: + (messageId) => scrollToMessage( + messageId: messageId, + messageList: messageList, + messagesNotifier: messagesNotifier, + listController: listController, + scrollController: scrollController, + ref: ref, ), - loading: - () => MessageItem( - message: message, - isCurrentUser: false, - onAction: null, - progress: null, - showAvatar: false, - onJump: (_) {}, - ), - error: (_, _) => const SizedBox.shrink(), + attachmentProgress: attachmentProgress.value, + disableAnimation: settings.disableAnimation, + roomOpenTime: roomOpenTime, ); - - return settings.disableAnimation - ? messageWidget - : TweenAnimationBuilder( - key: key, - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 400 + (index % 5) * 50), - curve: Curves.easeOutCubic, - builder: - (context, animationValue, child) => Transform.translate( - offset: Offset(0, 20 * (1 - animationValue)), - child: Opacity(opacity: animationValue, child: child), - ), - child: messageWidget, - ); }, ), ); diff --git a/lib/screens/chat/widgets/message_item_wrapper.dart b/lib/screens/chat/widgets/message_item_wrapper.dart new file mode 100644 index 00000000..0670dfdd --- /dev/null +++ b/lib/screens/chat/widgets/message_item_wrapper.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:island/database/message.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/widgets/chat/message_item.dart'; + +// Provider to track animated messages to prevent replay +final animatedMessagesProvider = StateProvider>((ref) => {}); + +class MessageItemWrapper extends HookConsumerWidget { + final LocalChatMessage message; + final int index; + final bool isLastInGroup; + final bool isSelectionMode; + final Set selectedMessages; + final AsyncValue chatIdentity; + final VoidCallback toggleSelectionMode; + final Function(String) toggleMessageSelection; + final Function(String, LocalChatMessage) onMessageAction; + final Function(String) onJump; + final Map> attachmentProgress; + final bool disableAnimation; + final DateTime roomOpenTime; + + const MessageItemWrapper({ + super.key, + required this.message, + required this.index, + required this.isLastInGroup, + required this.isSelectionMode, + required this.selectedMessages, + required this.chatIdentity, + required this.toggleSelectionMode, + required this.toggleMessageSelection, + required this.onMessageAction, + required this.onJump, + required this.attachmentProgress, + required this.disableAnimation, + required this.roomOpenTime, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Animation logic + final animatedMessages = ref.watch(animatedMessagesProvider); + final isNewMessage = message.createdAt.isAfter(roomOpenTime); + final hasAnimated = animatedMessages.contains(message.id); + + // Only animate if: + // 1. Animation is enabled + // 2. Message is new (created after room open) + // 3. Has not animated yet + final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated; + + final child = chatIdentity.when( + skipError: true, + data: (identity) => _buildContent(context, identity), + loading: () => _buildLoading(), + error: (_, __) => const SizedBox.shrink(), + ); + + if (!shouldAnimate) { + return child; + } + + return TweenAnimationBuilder( + key: ValueKey('anim-${message.id}'), // Ensure unique key for animation + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 400 + (index % 5) * 50), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Opacity(opacity: value, child: child), + ); + }, + onEnd: () { + // Mark as animated + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(animatedMessagesProvider.notifier) + .update((state) => {...state, message.id}); + }); + }, + child: child, + ); + } + + Widget _buildContent(BuildContext context, SnChatMember? identity) { + final isSelected = selectedMessages.contains(message.id); + final isCurrentUser = identity?.id == message.senderId; + + return GestureDetector( + onLongPress: () { + if (!isSelectionMode) { + toggleSelectionMode(); + toggleMessageSelection(message.id); + } + }, + onTap: () { + if (isSelectionMode) { + toggleMessageSelection(message.id); + } + }, + child: Container( + color: + isSelected + ? Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.3) + : null, + child: Stack( + children: [ + MessageItem( + // If animation is disabled, we might want to pass a key to maintain state? + // But here we are inside the wrapper. + key: ValueKey('item-${message.id}'), + message: message, + isCurrentUser: isCurrentUser, + onAction: + isSelectionMode + ? null + : (action) => onMessageAction(action, message), + onJump: onJump, + progress: attachmentProgress[message.id], + showAvatar: isLastInGroup, + isSelectionMode: isSelectionMode, + isSelected: isSelected, + onToggleSelection: toggleMessageSelection, + onEnterSelectionMode: () { + if (!isSelectionMode) toggleSelectionMode(); + }, + ), + if (isSelected) + Positioned( + top: 8, + right: 8, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + size: 12, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLoading() { + return MessageItem( + message: message, + isCurrentUser: false, + onAction: null, + progress: null, + showAvatar: false, + onJump: (_) {}, + ); + } +}