🐛 Fix message jumps in search
This commit is contained in:
		@@ -1,6 +1,7 @@
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:drift/wasm.dart';
 | 
			
		||||
import 'package:island/database/drift_db.dart';
 | 
			
		||||
import 'package:island/talker.dart';
 | 
			
		||||
 | 
			
		||||
AppDatabase constructDb() {
 | 
			
		||||
  return AppDatabase(connectOnWeb());
 | 
			
		||||
@@ -16,8 +17,8 @@ DatabaseConnection connectOnWeb() {
 | 
			
		||||
          driftWorkerUri: Uri.parse('drift_worker.dart.js'),
 | 
			
		||||
        );
 | 
			
		||||
        return result.resolvedExecutor;
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        print('Failed to open WASM database: $e');
 | 
			
		||||
      } catch (e, stackTrace) {
 | 
			
		||||
        talker.error('Failed to open WASM database...', e, stackTrace);
 | 
			
		||||
        rethrow;
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
 
 | 
			
		||||
@@ -118,14 +118,17 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
  Future<List<LocalChatMessage>> _getCachedMessages({
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
    int take = 20,
 | 
			
		||||
    String? searchQuery,
 | 
			
		||||
    bool? withLinks,
 | 
			
		||||
    bool? withAttachments,
 | 
			
		||||
  }) async {
 | 
			
		||||
    talker.log('Getting cached messages from offset $offset, take $take');
 | 
			
		||||
    final List<LocalChatMessage> dbMessages;
 | 
			
		||||
    if (_searchQuery != null && _searchQuery!.isNotEmpty) {
 | 
			
		||||
    if (searchQuery != null && searchQuery.isNotEmpty) {
 | 
			
		||||
      dbMessages = await _database.searchMessages(
 | 
			
		||||
        _roomId,
 | 
			
		||||
        _searchQuery ?? '',
 | 
			
		||||
        withAttachments: _withAttachments,
 | 
			
		||||
        searchQuery,
 | 
			
		||||
        withAttachments: withAttachments,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      final chatMessagesFromDb = await _database.getMessagesForRoom(
 | 
			
		||||
@@ -139,7 +142,7 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
 | 
			
		||||
    List<LocalChatMessage> filteredMessages = dbMessages;
 | 
			
		||||
 | 
			
		||||
    if (_withLinks == true) {
 | 
			
		||||
    if (withLinks == true) {
 | 
			
		||||
      filteredMessages =
 | 
			
		||||
          filteredMessages.where((msg) => _hasLink(msg)).toList();
 | 
			
		||||
    }
 | 
			
		||||
@@ -177,6 +180,51 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
    return uniqueMessages;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Get all messages without search filters for jump operations
 | 
			
		||||
  Future<List<LocalChatMessage>> _getAllMessagesForJump({
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
    int take = 20,
 | 
			
		||||
  }) async {
 | 
			
		||||
    talker.log('Getting all messages for jump from offset $offset, take $take');
 | 
			
		||||
    final chatMessagesFromDb = await _database.getMessagesForRoom(
 | 
			
		||||
      _roomId,
 | 
			
		||||
      offset: offset,
 | 
			
		||||
      limit: take,
 | 
			
		||||
    );
 | 
			
		||||
    final dbMessages =
 | 
			
		||||
        chatMessagesFromDb.map(_database.companionToMessage).toList();
 | 
			
		||||
 | 
			
		||||
    // Always ensure unique messages to prevent duplicate keys
 | 
			
		||||
    final uniqueMessages = <LocalChatMessage>[];
 | 
			
		||||
    final seenIds = <String>{};
 | 
			
		||||
    for (final message in dbMessages) {
 | 
			
		||||
      if (seenIds.add(message.id)) {
 | 
			
		||||
        uniqueMessages.add(message);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (offset == 0) {
 | 
			
		||||
      final pendingForRoom =
 | 
			
		||||
          _pendingMessages.values
 | 
			
		||||
              .where((msg) => msg.roomId == _roomId)
 | 
			
		||||
              .toList();
 | 
			
		||||
 | 
			
		||||
      final allMessages = [...pendingForRoom, ...uniqueMessages];
 | 
			
		||||
      _sortMessages(allMessages);
 | 
			
		||||
 | 
			
		||||
      final finalUniqueMessages = <LocalChatMessage>[];
 | 
			
		||||
      final finalSeenIds = <String>{};
 | 
			
		||||
      for (final message in allMessages) {
 | 
			
		||||
        if (finalSeenIds.add(message.id)) {
 | 
			
		||||
          finalUniqueMessages.add(message);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return finalUniqueMessages;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return uniqueMessages;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<LocalChatMessage>> _fetchAndCacheMessages({
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
    int take = 20,
 | 
			
		||||
@@ -309,6 +357,9 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      final localMessages = await _getCachedMessages(
 | 
			
		||||
        offset: offset,
 | 
			
		||||
        take: take,
 | 
			
		||||
        searchQuery: _searchQuery,
 | 
			
		||||
        withLinks: _withLinks,
 | 
			
		||||
        withAttachments: _withAttachments,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (localMessages.isNotEmpty) {
 | 
			
		||||
@@ -324,6 +375,9 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      final localMessages = await _getCachedMessages(
 | 
			
		||||
        offset: offset,
 | 
			
		||||
        take: take,
 | 
			
		||||
        searchQuery: _searchQuery,
 | 
			
		||||
        withLinks: _withLinks,
 | 
			
		||||
        withAttachments: _withAttachments,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (localMessages.isNotEmpty) {
 | 
			
		||||
@@ -339,7 +393,13 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      syncMessages();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final messages = await _getCachedMessages(offset: 0, take: _pageSize);
 | 
			
		||||
    final messages = await _getCachedMessages(
 | 
			
		||||
      offset: 0,
 | 
			
		||||
      take: _pageSize,
 | 
			
		||||
      searchQuery: _searchQuery,
 | 
			
		||||
      withLinks: _withLinks,
 | 
			
		||||
      withAttachments: _withAttachments,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _hasMore = messages.length == _pageSize;
 | 
			
		||||
 | 
			
		||||
@@ -571,6 +631,13 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
 | 
			
		||||
  Future<void> receiveMessage(SnChatMessage remoteMessage) async {
 | 
			
		||||
    if (remoteMessage.chatRoomId != _roomId) return;
 | 
			
		||||
 | 
			
		||||
    // Block message receiving during jumps to prevent list resets
 | 
			
		||||
    if (_isJumping) {
 | 
			
		||||
      talker.log('Blocking message receive during jump operation');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    talker.log('Received new message ${remoteMessage.id}');
 | 
			
		||||
 | 
			
		||||
    final localMessage = LocalChatMessage.fromRemoteMessage(
 | 
			
		||||
@@ -616,6 +683,13 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
 | 
			
		||||
  Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
 | 
			
		||||
    if (remoteMessage.chatRoomId != _roomId) return;
 | 
			
		||||
 | 
			
		||||
    // Block message updates during jumps to prevent list resets
 | 
			
		||||
    if (_isJumping) {
 | 
			
		||||
      talker.log('Blocking message update during jump operation');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    talker.log('Received message update ${remoteMessage.id}');
 | 
			
		||||
 | 
			
		||||
    final targetId = remoteMessage.meta['message_id'] ?? remoteMessage.id;
 | 
			
		||||
@@ -639,6 +713,12 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> receiveMessageDeletion(String messageId) async {
 | 
			
		||||
    // Block message deletions during jumps to prevent list resets
 | 
			
		||||
    if (_isJumping) {
 | 
			
		||||
      talker.log('Blocking message deletion during jump operation');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    talker.log('Received message deletion $messageId');
 | 
			
		||||
    _pendingMessages.remove(messageId);
 | 
			
		||||
 | 
			
		||||
@@ -691,6 +771,39 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Get search results without updating shared state
 | 
			
		||||
  Future<List<LocalChatMessage>> getSearchResults(
 | 
			
		||||
    String query, {
 | 
			
		||||
    bool? withLinks,
 | 
			
		||||
    bool? withAttachments,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final trimmedQuery = query.trim();
 | 
			
		||||
 | 
			
		||||
    if (trimmedQuery.isEmpty) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    talker.log('Getting search results for query: $trimmedQuery');
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final messages = await _getCachedMessages(
 | 
			
		||||
        offset: 0,
 | 
			
		||||
        take: 50,
 | 
			
		||||
        searchQuery: trimmedQuery,
 | 
			
		||||
        withLinks: withLinks,
 | 
			
		||||
        withAttachments: withAttachments,
 | 
			
		||||
      ); // Limit initial search results
 | 
			
		||||
      return messages;
 | 
			
		||||
    } catch (e, stackTrace) {
 | 
			
		||||
      talker.log(
 | 
			
		||||
        'Error getting search results',
 | 
			
		||||
        exception: e,
 | 
			
		||||
        stackTrace: stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> searchMessages(
 | 
			
		||||
    String query, {
 | 
			
		||||
    bool? withLinks,
 | 
			
		||||
@@ -712,6 +825,9 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      final messages = await _getCachedMessages(
 | 
			
		||||
        offset: 0,
 | 
			
		||||
        take: 50,
 | 
			
		||||
        searchQuery: _searchQuery,
 | 
			
		||||
        withLinks: _withLinks,
 | 
			
		||||
        withAttachments: _withAttachments,
 | 
			
		||||
      ); // Limit initial search results
 | 
			
		||||
      state = AsyncValue.data(messages);
 | 
			
		||||
    } catch (e, stackTrace) {
 | 
			
		||||
@@ -791,10 +907,11 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      talker.log(
 | 
			
		||||
        'Message $messageId not in current state, loading messages around it',
 | 
			
		||||
        'Message $messageId not in current state, calculating position and loading messages around it',
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Count messages newer than this one
 | 
			
		||||
      // Count messages newer than the target message to calculate optimal offset
 | 
			
		||||
      // Use full message list (not filtered by search) for accurate position calculation
 | 
			
		||||
      final query = _database.customSelect(
 | 
			
		||||
        'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
 | 
			
		||||
        variables: [
 | 
			
		||||
@@ -806,13 +923,17 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
      final result = await query.getSingle();
 | 
			
		||||
      final newerCount = result.read<int>('count');
 | 
			
		||||
 | 
			
		||||
      // Load messages around this position
 | 
			
		||||
      // Calculate offset to position target message in the middle of the loaded chunk
 | 
			
		||||
      const chunkSize = 100; // Load 100 messages around the target
 | 
			
		||||
      final offset =
 | 
			
		||||
          (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
 | 
			
		||||
      talker.log('Loading messages with offset $offset, take $_pageSize');
 | 
			
		||||
      final loadedMessages = await _getCachedMessages(
 | 
			
		||||
          (newerCount - chunkSize ~/ 2).clamp(0, double.infinity).toInt();
 | 
			
		||||
      talker.log(
 | 
			
		||||
        'Calculated offset $offset for target message (newer: $newerCount, chunk: $chunkSize)',
 | 
			
		||||
      );
 | 
			
		||||
      // Use full message list (not filtered by search) for jump operations
 | 
			
		||||
      final loadedMessages = await _getAllMessagesForJump(
 | 
			
		||||
        offset: offset,
 | 
			
		||||
        take: _pageSize,
 | 
			
		||||
        take: chunkSize,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Check if loaded messages are already in current state
 | 
			
		||||
@@ -839,10 +960,31 @@ class MessagesNotifier extends _$MessagesNotifier {
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Wait a bit for the UI to rebuild with new messages
 | 
			
		||||
      await Future.delayed(const Duration(milliseconds: 100));
 | 
			
		||||
 | 
			
		||||
      final finalIndex = (state.value ?? []).indexWhere(
 | 
			
		||||
        (m) => m.id == messageId,
 | 
			
		||||
      );
 | 
			
		||||
      talker.log('Final index for message $messageId is $finalIndex');
 | 
			
		||||
 | 
			
		||||
      // Verify the message is actually in the list before returning
 | 
			
		||||
      if (finalIndex == -1) {
 | 
			
		||||
        talker.log(
 | 
			
		||||
          'Message $messageId still not found after loading, trying direct fetch',
 | 
			
		||||
        );
 | 
			
		||||
        // Try to fetch and add the specific message if it's still not found
 | 
			
		||||
        final directMessage = await fetchMessageById(messageId);
 | 
			
		||||
        if (directMessage != null) {
 | 
			
		||||
          final currentList = state.value ?? [];
 | 
			
		||||
          final updatedList = [...currentList, directMessage];
 | 
			
		||||
          await _updateStateSafely(updatedList);
 | 
			
		||||
          final newIndex = updatedList.indexWhere((m) => m.id == messageId);
 | 
			
		||||
          talker.log('Added message directly, new index: $newIndex');
 | 
			
		||||
          return newIndex;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return finalIndex;
 | 
			
		||||
    } finally {
 | 
			
		||||
      _isJumping = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ import "package:island/pods/chat/chat_subscribe.dart";
 | 
			
		||||
import "package:island/pods/chat/messages_notifier.dart";
 | 
			
		||||
import "package:island/pods/network.dart";
 | 
			
		||||
import "package:island/pods/chat/chat_online_count.dart";
 | 
			
		||||
import "package:island/screens/chat/search_messages.dart";
 | 
			
		||||
import "package:island/services/file_uploader.dart";
 | 
			
		||||
import "package:island/screens/chat/chat.dart";
 | 
			
		||||
import "package:island/services/responsive.dart";
 | 
			
		||||
@@ -142,6 +143,7 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
    final attachmentProgress = useState<Map<String, Map<int, double>>>({});
 | 
			
		||||
 | 
			
		||||
    var isLoading = false;
 | 
			
		||||
    var isScrollingToMessage = false; // Flag to prevent scroll conflicts
 | 
			
		||||
 | 
			
		||||
    final listController = useMemoized(() => ListController(), []);
 | 
			
		||||
 | 
			
		||||
@@ -330,6 +332,94 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    const messageKeyPrefix = 'message-';
 | 
			
		||||
 | 
			
		||||
    // Helper function for scroll animation
 | 
			
		||||
    void performScrollAnimation({
 | 
			
		||||
      required int index,
 | 
			
		||||
      required ListController listController,
 | 
			
		||||
      required ScrollController scrollController,
 | 
			
		||||
      required String messageId,
 | 
			
		||||
      required WidgetRef ref,
 | 
			
		||||
    }) {
 | 
			
		||||
      // Update flashing message first
 | 
			
		||||
      ref
 | 
			
		||||
          .read(flashingMessagesProvider.notifier)
 | 
			
		||||
          .update((set) => set.union({messageId}));
 | 
			
		||||
 | 
			
		||||
      // Use multiple post-frame callbacks to ensure stability
 | 
			
		||||
      WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          try {
 | 
			
		||||
            listController.animateToItem(
 | 
			
		||||
              index: index,
 | 
			
		||||
              scrollController: scrollController,
 | 
			
		||||
              alignment: 0.5,
 | 
			
		||||
              duration:
 | 
			
		||||
                  (estimatedDistance) => Duration(
 | 
			
		||||
                    milliseconds:
 | 
			
		||||
                        (estimatedDistance * 0.5).clamp(200, 800).toInt(),
 | 
			
		||||
                  ),
 | 
			
		||||
              curve: (estimatedDistance) => Curves.easeOutCubic,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // Reset the scroll flag after animation completes
 | 
			
		||||
            Future.delayed(const Duration(milliseconds: 800), () {
 | 
			
		||||
              isScrollingToMessage = false;
 | 
			
		||||
            });
 | 
			
		||||
          } catch (e) {
 | 
			
		||||
            // If animation fails, reset the flag
 | 
			
		||||
            isScrollingToMessage = false;
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Robust scroll-to-message function to prevent jumping back
 | 
			
		||||
    void scrollToMessage({
 | 
			
		||||
      required String messageId,
 | 
			
		||||
      required List<LocalChatMessage> messageList,
 | 
			
		||||
      required MessagesNotifier messagesNotifier,
 | 
			
		||||
      required ListController listController,
 | 
			
		||||
      required ScrollController scrollController,
 | 
			
		||||
      required WidgetRef ref,
 | 
			
		||||
    }) {
 | 
			
		||||
      // Prevent concurrent scroll operations
 | 
			
		||||
      if (isScrollingToMessage) return;
 | 
			
		||||
      isScrollingToMessage = true;
 | 
			
		||||
 | 
			
		||||
      final messageIndex = messageList.indexWhere((m) => m.id == messageId);
 | 
			
		||||
 | 
			
		||||
      if (messageIndex == -1) {
 | 
			
		||||
        // Message not in current list, need to load it first
 | 
			
		||||
        messagesNotifier.jumpToMessage(messageId).then((index) {
 | 
			
		||||
          if (index != -1) {
 | 
			
		||||
            // Wait for UI to rebuild before animating
 | 
			
		||||
            WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
              performScrollAnimation(
 | 
			
		||||
                index: index,
 | 
			
		||||
                listController: listController,
 | 
			
		||||
                scrollController: scrollController,
 | 
			
		||||
                messageId: messageId,
 | 
			
		||||
                ref: ref,
 | 
			
		||||
              );
 | 
			
		||||
            });
 | 
			
		||||
          } else {
 | 
			
		||||
            isScrollingToMessage = false;
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        // Message is already in list, scroll directly with slight delay
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          performScrollAnimation(
 | 
			
		||||
            index: messageIndex,
 | 
			
		||||
            listController: listController,
 | 
			
		||||
            scrollController: scrollController,
 | 
			
		||||
            messageId: messageId,
 | 
			
		||||
            ref: ref,
 | 
			
		||||
          );
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> uploadAttachment(int index) async {
 | 
			
		||||
      final attachment = attachments.value[index];
 | 
			
		||||
      if (attachment.isOnCloud) return;
 | 
			
		||||
@@ -445,43 +535,14 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
                  }
 | 
			
		||||
                },
 | 
			
		||||
                onJump: (messageId) {
 | 
			
		||||
                  final messageIndex = messageList.indexWhere(
 | 
			
		||||
                    (m) => m.id == messageId,
 | 
			
		||||
                  scrollToMessage(
 | 
			
		||||
                    messageId: messageId,
 | 
			
		||||
                    messageList: messageList,
 | 
			
		||||
                    messagesNotifier: messagesNotifier,
 | 
			
		||||
                    listController: listController,
 | 
			
		||||
                    scrollController: scrollController,
 | 
			
		||||
                    ref: ref,
 | 
			
		||||
                  );
 | 
			
		||||
                  if (messageIndex == -1) {
 | 
			
		||||
                    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;
 | 
			
		||||
                  }
 | 
			
		||||
                  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,
 | 
			
		||||
@@ -528,46 +589,40 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
                'chatDetail',
 | 
			
		||||
                pathParameters: {'id': id},
 | 
			
		||||
              );
 | 
			
		||||
              if (result is String && messages.valueOrNull != null) {
 | 
			
		||||
                // Jump to the message that was selected in search
 | 
			
		||||
                final messageList = messages.valueOrNull!;
 | 
			
		||||
                final messageIndex = messageList.indexWhere(
 | 
			
		||||
                  (m) => m.id == result,
 | 
			
		||||
                );
 | 
			
		||||
                if (messageIndex == -1) {
 | 
			
		||||
                  messagesNotifier.jumpToMessage(result).then((index) {
 | 
			
		||||
                    if (index != -1) {
 | 
			
		||||
                      WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
              if (result is SearchMessagesResult &&
 | 
			
		||||
                  messages.valueOrNull != null) {
 | 
			
		||||
                final messageId = result.messageId;
 | 
			
		||||
 | 
			
		||||
                // Jump to the message and trigger flash effect
 | 
			
		||||
                messagesNotifier.jumpToMessage(messageId).then((index) {
 | 
			
		||||
                  if (index != -1 && context.mounted) {
 | 
			
		||||
                    // Update flashing message
 | 
			
		||||
                    ref
 | 
			
		||||
                        .read(flashingMessagesProvider.notifier)
 | 
			
		||||
                        .update((set) => set.union({messageId}));
 | 
			
		||||
 | 
			
		||||
                    // Scroll to the message with animation
 | 
			
		||||
                    WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
                      try {
 | 
			
		||||
                        listController.animateToItem(
 | 
			
		||||
                          index: index,
 | 
			
		||||
                          scrollController: scrollController,
 | 
			
		||||
                          alignment: 0.5,
 | 
			
		||||
                          duration:
 | 
			
		||||
                              (estimatedDistance) =>
 | 
			
		||||
                                  Duration(milliseconds: 250),
 | 
			
		||||
                          curve: (estimatedDistance) => Curves.easeInOut,
 | 
			
		||||
                              (estimatedDistance) => Duration(
 | 
			
		||||
                                milliseconds:
 | 
			
		||||
                                    (estimatedDistance * 0.5)
 | 
			
		||||
                                        .clamp(200, 800)
 | 
			
		||||
                                        .toInt(),
 | 
			
		||||
                              ),
 | 
			
		||||
                          curve: (estimatedDistance) => Curves.easeOutCubic,
 | 
			
		||||
                        );
 | 
			
		||||
                      });
 | 
			
		||||
                      ref
 | 
			
		||||
                          .read(flashingMessagesProvider.notifier)
 | 
			
		||||
                          .update((set) => set.union({result}));
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                } else {
 | 
			
		||||
                  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({result}));
 | 
			
		||||
                }
 | 
			
		||||
                      } catch (e) {
 | 
			
		||||
                        // If animation fails, just update flashing state
 | 
			
		||||
                      }
 | 
			
		||||
                    });
 | 
			
		||||
                  }
 | 
			
		||||
                });
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
 
 | 
			
		||||
@@ -410,7 +410,7 @@ class ChatDetailScreen extends HookConsumerWidget {
 | 
			
		||||
                                    if (result is SearchMessagesResult) {
 | 
			
		||||
                                      // Navigate back to room screen with message to jump to
 | 
			
		||||
                                      if (context.mounted) {
 | 
			
		||||
                                        context.pop(result.messageId);
 | 
			
		||||
                                        context.pop(result);
 | 
			
		||||
                                      }
 | 
			
		||||
                                    }
 | 
			
		||||
                                  },
 | 
			
		||||
 
 | 
			
		||||
@@ -32,6 +32,9 @@ class SearchMessagesScreen extends HookConsumerWidget {
 | 
			
		||||
    final withAttachments = useState(false);
 | 
			
		||||
    final searchState = useState(SearchState.idle);
 | 
			
		||||
    final searchResultCount = useState<int?>(null);
 | 
			
		||||
    final searchResults = useState<AsyncValue<List<dynamic>>>(
 | 
			
		||||
      const AsyncValue.data([]),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Debounce timer for search optimization
 | 
			
		||||
    final debounceTimer = useRef<Timer?>(null);
 | 
			
		||||
@@ -39,61 +42,47 @@ class SearchMessagesScreen extends HookConsumerWidget {
 | 
			
		||||
    final messagesNotifier = ref.read(
 | 
			
		||||
      messagesNotifierProvider(roomId).notifier,
 | 
			
		||||
    );
 | 
			
		||||
    final messages = ref.watch(messagesNotifierProvider(roomId));
 | 
			
		||||
 | 
			
		||||
    // Optimized search function with debouncing
 | 
			
		||||
    void performSearch(String query) {
 | 
			
		||||
    void performSearch(String query) async {
 | 
			
		||||
      if (query.trim().isEmpty) {
 | 
			
		||||
        searchState.value = SearchState.idle;
 | 
			
		||||
        searchResultCount.value = null;
 | 
			
		||||
        messagesNotifier.clearSearch();
 | 
			
		||||
        searchResults.value = const AsyncValue.data([]);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      searchState.value = SearchState.searching;
 | 
			
		||||
      searchResults.value = const AsyncValue.loading();
 | 
			
		||||
 | 
			
		||||
      // Cancel previous search if still active
 | 
			
		||||
      debounceTimer.value?.cancel();
 | 
			
		||||
 | 
			
		||||
      // Debounce search to avoid excessive API calls
 | 
			
		||||
      debounceTimer.value = Timer(const Duration(milliseconds: 300), () {
 | 
			
		||||
        messagesNotifier.searchMessages(
 | 
			
		||||
          query.trim(),
 | 
			
		||||
          withLinks: withLinks.value,
 | 
			
		||||
          withAttachments: withAttachments.value,
 | 
			
		||||
        );
 | 
			
		||||
      debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
 | 
			
		||||
        try {
 | 
			
		||||
          final results = await messagesNotifier.getSearchResults(
 | 
			
		||||
            query.trim(),
 | 
			
		||||
            withLinks: withLinks.value,
 | 
			
		||||
            withAttachments: withAttachments.value,
 | 
			
		||||
          );
 | 
			
		||||
          searchResults.value = AsyncValue.data(results);
 | 
			
		||||
          searchState.value =
 | 
			
		||||
              results.isEmpty ? SearchState.noResults : SearchState.results;
 | 
			
		||||
          searchResultCount.value = results.length;
 | 
			
		||||
        } catch (error, stackTrace) {
 | 
			
		||||
          searchResults.value = AsyncValue.error(error, stackTrace);
 | 
			
		||||
          searchState.value = SearchState.error;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Update search state based on messages state
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      messages.when(
 | 
			
		||||
        data: (messageList) {
 | 
			
		||||
          if (searchState.value == SearchState.searching) {
 | 
			
		||||
            searchState.value =
 | 
			
		||||
                messageList.isEmpty
 | 
			
		||||
                    ? SearchState.noResults
 | 
			
		||||
                    : SearchState.results;
 | 
			
		||||
            searchResultCount.value = messageList.length;
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        loading: () {
 | 
			
		||||
          if (searchController.text.trim().isNotEmpty) {
 | 
			
		||||
            searchState.value = SearchState.searching;
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        error: (error, stack) {
 | 
			
		||||
          searchState.value = SearchState.error;
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [messages]);
 | 
			
		||||
    // Search state is now managed locally in performSearch
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      // Clear search when screen is disposed
 | 
			
		||||
      return () {
 | 
			
		||||
        debounceTimer.value?.cancel();
 | 
			
		||||
        messagesNotifier.clearSearch();
 | 
			
		||||
        // Note: Don't access ref here as widget may be disposed
 | 
			
		||||
        // Flashing messages will be cleared by the next screen or jump operation
 | 
			
		||||
      };
 | 
			
		||||
@@ -228,7 +217,7 @@ class SearchMessagesScreen extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
          // Search results section
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: messages.when(
 | 
			
		||||
            child: searchResults.value.when(
 | 
			
		||||
              data: (messageList) {
 | 
			
		||||
                switch (searchState.value) {
 | 
			
		||||
                  case SearchState.idle:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:island/models/embed.dart';
 | 
			
		||||
import 'package:island/models/poll.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/talker.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
@@ -44,32 +45,22 @@ class PostFeaturedList extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    // Log isCollapsed state changes
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      debugPrint(
 | 
			
		||||
        'PostFeaturedList: isCollapsed changed to ${isCollapsed.value}',
 | 
			
		||||
      );
 | 
			
		||||
      talker.info('isCollapsed changed to ${isCollapsed.value}');
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [isCollapsed]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) {
 | 
			
		||||
        final currentFirstPostId = featuredPostsAsync.value!.first.id;
 | 
			
		||||
        debugPrint(
 | 
			
		||||
          'PostFeaturedList: Current first post ID: $currentFirstPostId',
 | 
			
		||||
        );
 | 
			
		||||
        debugPrint(
 | 
			
		||||
          'PostFeaturedList: Previous first post ID: ${previousFirstPostId.value}',
 | 
			
		||||
        );
 | 
			
		||||
        debugPrint(
 | 
			
		||||
          'PostFeaturedList: Stored collapsed ID: ${storedCollapsedId.value}',
 | 
			
		||||
        );
 | 
			
		||||
        talker.info('Current first post ID: $currentFirstPostId');
 | 
			
		||||
        talker.info('Previous first post ID: ${previousFirstPostId.value}');
 | 
			
		||||
        talker.info('Stored collapsed ID: ${storedCollapsedId.value}');
 | 
			
		||||
 | 
			
		||||
        if (previousFirstPostId.value == null) {
 | 
			
		||||
          // Initial load
 | 
			
		||||
          previousFirstPostId.value = currentFirstPostId;
 | 
			
		||||
          isCollapsed.value = (storedCollapsedId.value == currentFirstPostId);
 | 
			
		||||
          debugPrint(
 | 
			
		||||
            'PostFeaturedList: Initial load. isCollapsed set to ${isCollapsed.value}',
 | 
			
		||||
          );
 | 
			
		||||
          talker.info('Initial load. isCollapsed set to ${isCollapsed.value}');
 | 
			
		||||
        } else if (previousFirstPostId.value != currentFirstPostId) {
 | 
			
		||||
          // First post changed, expand by default
 | 
			
		||||
          previousFirstPostId.value = currentFirstPostId;
 | 
			
		||||
@@ -77,20 +68,14 @@ class PostFeaturedList extends HookConsumerWidget {
 | 
			
		||||
          prefs.remove(
 | 
			
		||||
            kFeaturedPostsCollapsedId,
 | 
			
		||||
          ); // Clear stored ID if post changes
 | 
			
		||||
          debugPrint(
 | 
			
		||||
            'PostFeaturedList: First post changed. isCollapsed set to false.',
 | 
			
		||||
          );
 | 
			
		||||
          talker.info('First post changed. isCollapsed set to false.');
 | 
			
		||||
        } else {
 | 
			
		||||
          // Same first post, maintain current collapse state
 | 
			
		||||
          // No change needed for isCollapsed.value unless manually toggled
 | 
			
		||||
          debugPrint(
 | 
			
		||||
            'PostFeaturedList: Same first post. Maintaining current collapse state.',
 | 
			
		||||
          );
 | 
			
		||||
          talker.info('Same first post. Maintaining current collapse state.');
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        debugPrint(
 | 
			
		||||
          'PostFeaturedList: featuredPostsAsync has no value or is empty.',
 | 
			
		||||
        );
 | 
			
		||||
        talker.info('featuredPostsAsync has no value or is empty.');
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [featuredPostsAsync]);
 | 
			
		||||
@@ -142,8 +127,8 @@ class PostFeaturedList extends HookConsumerWidget {
 | 
			
		||||
                    constraints: const BoxConstraints(),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      isCollapsed.value = !isCollapsed.value;
 | 
			
		||||
                      debugPrint(
 | 
			
		||||
                        'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}',
 | 
			
		||||
                      talker.info(
 | 
			
		||||
                        'Manual toggle. isCollapsed set to ${isCollapsed.value}',
 | 
			
		||||
                      );
 | 
			
		||||
                      if (isCollapsed.value &&
 | 
			
		||||
                          featuredPostsAsync.hasValue &&
 | 
			
		||||
@@ -152,14 +137,12 @@ class PostFeaturedList extends HookConsumerWidget {
 | 
			
		||||
                          kFeaturedPostsCollapsedId,
 | 
			
		||||
                          featuredPostsAsync.value!.first.id,
 | 
			
		||||
                        );
 | 
			
		||||
                        debugPrint(
 | 
			
		||||
                          'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
 | 
			
		||||
                        talker.info(
 | 
			
		||||
                          'Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
 | 
			
		||||
                        );
 | 
			
		||||
                      } else {
 | 
			
		||||
                        prefs.remove(kFeaturedPostsCollapsedId);
 | 
			
		||||
                        debugPrint(
 | 
			
		||||
                          'PostFeaturedList: Removed stored collapsed ID.',
 | 
			
		||||
                        );
 | 
			
		||||
                        talker.info('Removed stored collapsed ID.');
 | 
			
		||||
                      }
 | 
			
		||||
                    },
 | 
			
		||||
                    icon: Icon(
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user