From 06bb18bdaaab49ebdb2fc8c046a8043b327f9dd9 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 23 Sep 2025 16:19:54 +0800 Subject: [PATCH] :lipstick: Flashing message background when jumped --- lib/screens/chat/room.dart | 173 ++++++++++++++++++++++++++--- lib/screens/chat/room.g.dart | 2 +- lib/widgets/chat/message_item.dart | 46 +++++++- 3 files changed, 202 insertions(+), 19 deletions(-) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 411d61f8..40aedb56 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -3,6 +3,7 @@ import "dart:convert"; import "dart:developer" as developer; import "dart:io"; import "package:dio/dio.dart"; +import "package:drift/drift.dart" show Variable; import "package:easy_localization/easy_localization.dart"; import "package:file_picker/file_picker.dart"; import "package:flutter/foundation.dart"; @@ -46,6 +47,8 @@ part 'room.g.dart'; final isSyncingProvider = StateProvider.autoDispose((ref) => false); +final flashingMessagesProvider = StateProvider>((ref) => {}); + final appLifecycleStateProvider = StreamProvider((ref) { final controller = StreamController(); @@ -292,6 +295,7 @@ class MessagesNotifier extends _$MessagesNotifier { static const int _pageSize = 20; bool _hasMore = true; bool _isSyncing = false; + bool _isJumping = false; @override FutureOr> build(String roomId) async { @@ -372,26 +376,35 @@ class MessagesNotifier extends _$MessagesNotifier { final dbLocalMessages = filteredMessages; + // Always ensure unique messages to prevent duplicate keys + final uniqueMessages = []; + final seenIds = {}; + for (final message in dbLocalMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + if (offset == 0) { final pendingForRoom = _pendingMessages.values .where((msg) => msg.roomId == _roomId) .toList(); - final allMessages = [...pendingForRoom, ...dbLocalMessages]; + final allMessages = [...pendingForRoom, ...uniqueMessages]; _sortMessages(allMessages); // Use the helper function - final uniqueMessages = []; - final seenIds = {}; + final finalUniqueMessages = []; + final finalSeenIds = {}; for (final message in allMessages) { - if (seenIds.add(message.id)) { - uniqueMessages.add(message); + if (finalSeenIds.add(message.id)) { + finalUniqueMessages.add(message); } } - return uniqueMessages; + return finalUniqueMessages; } - return dbLocalMessages; + return uniqueMessages; } Future> _fetchAndCacheMessages({ @@ -982,6 +995,111 @@ class MessagesNotifier extends _$MessagesNotifier { } } + Future jumpToMessage(String messageId) async { + developer.log( + 'Starting jump to message $messageId', + name: 'MessagesNotifier', + ); + if (_isJumping) { + developer.log( + 'Jump already in progress, skipping', + name: 'MessagesNotifier', + ); + return -1; + } + _isJumping = true; + + try { + developer.log('Fetching message $messageId', name: 'MessagesNotifier'); + final message = await fetchMessageById(messageId); + if (message == null) { + developer.log('Message $messageId not found', name: 'MessagesNotifier'); + showSnackBar('messageNotFound'.tr()); + return -1; + } + + // Check if message is already in current state to avoid duplicate loading + final currentMessages = state.value ?? []; + final existingIndex = currentMessages.indexWhere( + (m) => m.id == messageId, + ); + if (existingIndex >= 0) { + developer.log( + 'Message $messageId already in current state at index $existingIndex, jumping directly', + name: 'MessagesNotifier', + ); + return existingIndex; + } + + developer.log( + 'Message $messageId not in current state, loading messages around it', + name: 'MessagesNotifier', + ); + + // Count messages newer than this one + final query = _database.customSelect( + 'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?', + variables: [ + Variable.withString(_roomId), + Variable.withDateTime(message.createdAt), + ], + readsFrom: {_database.chatMessages}, + ); + final result = await query.getSingle(); + final newerCount = result.read('count'); + + // Load messages around this position + final offset = + (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); + developer.log( + 'Loading messages with offset $offset, take $_pageSize', + name: 'MessagesNotifier', + ); + final loadedMessages = await _getCachedMessages( + offset: offset, + take: _pageSize, + ); + + // Check if loaded messages are already in current state + final currentIds = currentMessages.map((m) => m.id).toSet(); + final newMessages = + loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); + developer.log( + 'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', + name: 'MessagesNotifier', + ); + + if (newMessages.isNotEmpty) { + // Merge with current messages + final allMessages = [...currentMessages, ...newMessages]; + final uniqueMessages = []; + final seenIds = {}; + for (final message in allMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + _sortMessages(uniqueMessages); + state = AsyncValue.data(uniqueMessages); + developer.log( + 'Updated state with ${uniqueMessages.length} total messages', + name: 'MessagesNotifier', + ); + } + + final finalIndex = (state.value ?? []).indexWhere( + (m) => m.id == messageId, + ); + developer.log( + 'Final index for message $messageId is $finalIndex', + name: 'MessagesNotifier', + ); + return finalIndex; + } finally { + _isJumping = false; + } + } + bool _hasLink(LocalChatMessage message) { final content = message.toRemoteMessage().content; if (content == null) return false; @@ -1454,17 +1572,40 @@ class ChatRoomScreen extends HookConsumerWidget { (m) => m.id == messageId, ); if (messageIndex == -1) { - showSnackBar('messageJumpNotLoaded'.tr()); + messagesNotifier.jumpToMessage(messageId).then((index) { + if (index != -1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + listController.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: + (estimatedDistance) => + Duration(milliseconds: 250), + curve: (estimatedDistance) => Curves.easeInOut, + ); + }); + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.union({messageId})); + } + }); return; } - listController.animateToItem( - index: messageIndex, - scrollController: scrollController, - alignment: 0.5, - duration: - (estimatedDistance) => Duration(milliseconds: 250), - curve: (estimatedDistance) => Curves.easeInOut, - ); + WidgetsBinding.instance.addPostFrameCallback((_) { + listController.animateToItem( + index: messageIndex, + scrollController: scrollController, + alignment: 0.5, + duration: + (estimatedDistance) => + Duration(milliseconds: 250), + curve: (estimatedDistance) => Curves.easeInOut, + ); + }); + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.union({messageId})); }, progress: attachmentProgress.value[message.id], showAvatar: isLastInGroup, diff --git a/lib/screens/chat/room.g.dart b/lib/screens/chat/room.g.dart index 937c3647..c6d4556f 100644 --- a/lib/screens/chat/room.g.dart +++ b/lib/screens/chat/room.g.dart @@ -6,7 +6,7 @@ part of 'room.dart'; // RiverpodGenerator // ************************************************************************** -String _$messagesNotifierHash() => r'196fe42438c716b2f975f5f14733974174b3dde7'; +String _$messagesNotifierHash() => r'5787fcac9f6c77062aaf854daf2365464f771c2f'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 94df7a53..309db1a5 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -67,6 +68,46 @@ class MessageItem extends HookConsumerWidget { final hasBackground = ref.watch(backgroundImageFileProvider).valueOrNull != null; + final flashing = ref.watch( + flashingMessagesProvider.select((set) => set.contains(message.id)), + ); + + final isFlashing = useState(false); + final flashTimer = useState(null); + + useEffect(() { + if (flashing) { + if (flashTimer.value != null) return null; + isFlashing.value = true; + flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), ( + timer, + ) { + isFlashing.value = !isFlashing.value; + if (timer.tick >= 4) { + // 4 ticks: true, false, true, false + timer.cancel(); + flashTimer.value = null; + isFlashing.value = false; + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.difference({message.id})); + } + }); + } else { + flashTimer.value?.cancel(); + flashTimer.value = null; + isFlashing.value = false; + } + return () { + flashTimer.value?.cancel(); + }; + }, [flashing]); + + final flashColor = + isFlashing.value + ? Theme.of(context).colorScheme.primary.withOpacity(0.8) + : containerColor; + final remoteMessage = message.toRemoteMessage(); final sender = remoteMessage.sender; @@ -253,9 +294,10 @@ class MessageItem extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: containerColor, + color: flashColor, borderRadius: BorderRadius.circular(16), ), padding: const EdgeInsets.symmetric(