diff --git a/lib/database/database.web.dart b/lib/database/database.web.dart index b6b6ea54..c9ca788f 100644 --- a/lib/database/database.web.dart +++ b/lib/database/database.web.dart @@ -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; } }), diff --git a/lib/pods/chat/messages_notifier.dart b/lib/pods/chat/messages_notifier.dart index 81f778e1..e1d7f314 100644 --- a/lib/pods/chat/messages_notifier.dart +++ b/lib/pods/chat/messages_notifier.dart @@ -118,14 +118,17 @@ class MessagesNotifier extends _$MessagesNotifier { Future> _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 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 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> _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 = []; + final seenIds = {}; + 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 = []; + final finalSeenIds = {}; + for (final message in allMessages) { + if (finalSeenIds.add(message.id)) { + finalUniqueMessages.add(message); + } + } + return finalUniqueMessages; + } + + return uniqueMessages; + } + Future> _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 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 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 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> 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 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('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; diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 207f25d3..e1e88843 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -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>>({}); 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 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 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 + } + }); + } + }); } }, ), diff --git a/lib/screens/chat/room_detail.dart b/lib/screens/chat/room_detail.dart index eb561bd7..211def89 100644 --- a/lib/screens/chat/room_detail.dart +++ b/lib/screens/chat/room_detail.dart @@ -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); } } }, diff --git a/lib/screens/chat/search_messages.dart b/lib/screens/chat/search_messages.dart index becf428b..c07193ed 100644 --- a/lib/screens/chat/search_messages.dart +++ b/lib/screens/chat/search_messages.dart @@ -32,6 +32,9 @@ class SearchMessagesScreen extends HookConsumerWidget { final withAttachments = useState(false); final searchState = useState(SearchState.idle); final searchResultCount = useState(null); + final searchResults = useState>>( + const AsyncValue.data([]), + ); // Debounce timer for search optimization final debounceTimer = useRef(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: diff --git a/lib/widgets/content/embed/embed_list.dart b/lib/widgets/content/embed/embed_list.dart index e1ae80dd..ef49e22d 100644 --- a/lib/widgets/content/embed/embed_list.dart +++ b/lib/widgets/content/embed/embed_list.dart @@ -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'; diff --git a/lib/widgets/post/post_featured.dart b/lib/widgets/post/post_featured.dart index cc1a348d..6fd576e3 100644 --- a/lib/widgets/post/post_featured.dart +++ b/lib/widgets/post/post_featured.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(