diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 56fcccf9..4583766f 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -263,7 +263,6 @@ "walletCurrencyShortPoints": "NSP", "walletCurrencyGolds": "The Solar Dollars", "walletCurrencyShortGolds": "NSD", - "retry": "Retry", "creatorHubUnselectedHint": "Pick / create a publisher to get started.", "relationships": "Relationships", "addFriend": "Send a Friend Request", @@ -1020,6 +1019,10 @@ "searchLinks": "Links", "searchAttachments": "Attachments", "noMessagesFound": "No messages found", + "Searching...": "Searching...", + "searchError": "Search failed. Please try again.", + "tryDifferentKeywords": "Try different keywords or remove search filters", + "retry": "Retry", "openInBrowser": "Open in Browser", "highlightPost": "Highlight Post", "filters": "Filters", diff --git a/lib/pods/chat/messages_notifier.dart b/lib/pods/chat/messages_notifier.dart index ab27497d..81f778e1 100644 --- a/lib/pods/chat/messages_notifier.dart +++ b/lib/pods/chat/messages_notifier.dart @@ -39,6 +39,7 @@ class MessagesNotifier extends _$MessagesNotifier { bool _hasMore = true; bool _isSyncing = false; bool _isJumping = false; + bool _isUpdatingState = false; DateTime? _lastPauseTime; @override @@ -92,6 +93,28 @@ class MessagesNotifier extends _$MessagesNotifier { return messages; } + Future _updateStateSafely(List messages) async { + if (_isUpdatingState) { + talker.log('State update already in progress, skipping'); + return; + } + _isUpdatingState = true; + try { + // Ensure messages are properly sorted and deduplicated + final sortedMessages = _sortMessages(messages); + final uniqueMessages = []; + final seenIds = {}; + for (final message in sortedMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + state = AsyncValue.data(uniqueMessages); + } finally { + _isUpdatingState = false; + } + } + Future> _getCachedMessages({ int offset = 0, int take = 20, @@ -668,11 +691,37 @@ class MessagesNotifier extends _$MessagesNotifier { } } - void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { + Future searchMessages( + String query, { + bool? withLinks, + bool? withAttachments, + }) async { _searchQuery = query.trim(); _withLinks = withLinks; _withAttachments = withAttachments; - loadInitial(); + + if (_searchQuery!.isEmpty) { + state = AsyncValue.data([]); + return; + } + + talker.log('Searching messages with query: $_searchQuery'); + state = const AsyncValue.loading(); + + try { + final messages = await _getCachedMessages( + offset: 0, + take: 50, + ); // Limit initial search results + state = AsyncValue.data(messages); + } catch (e, stackTrace) { + talker.log( + 'Error searching messages', + exception: e, + stackTrace: stackTrace, + ); + state = AsyncValue.error(e, stackTrace); + } } void clearSearch() { @@ -717,6 +766,9 @@ class MessagesNotifier extends _$MessagesNotifier { } _isJumping = true; + // Clear flashing messages when starting a new jump + ref.read(flashingMessagesProvider.notifier).state = {}; + try { talker.log('Fetching message $messageId'); final message = await fetchMessageById(messageId); @@ -772,7 +824,7 @@ class MessagesNotifier extends _$MessagesNotifier { ); if (newMessages.isNotEmpty) { - // Merge with current messages + // Merge with current messages more safely final allMessages = [...currentMessages, ...newMessages]; final uniqueMessages = []; final seenIds = {}; @@ -781,8 +833,7 @@ class MessagesNotifier extends _$MessagesNotifier { uniqueMessages.add(message); } } - _sortMessages(uniqueMessages); - state = AsyncValue.data(uniqueMessages); + await _updateStateSafely(uniqueMessages); talker.log( 'Updated state with ${uniqueMessages.length} total messages', ); diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index e9ec7334..207f25d3 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -385,121 +385,121 @@ class ChatRoomScreen extends HookConsumerWidget { } } - Widget chatMessageListWidget(List messageList) => - SuperListView.builder( - listController: listController, - padding: EdgeInsets.only( - top: 16, - bottom: 80 + MediaQuery.of(context).padding.bottom, - ), - controller: scrollController, - reverse: true, // Show newest messages at the bottom - itemCount: messageList.length, - findChildIndexCallback: (key) { - final valueKey = key as ValueKey; - final messageId = (valueKey.value as String).substring( - messageKeyPrefix.length, - ); - return messageList.indexWhere((m) => m.id == messageId); - }, - extentEstimation: (_, _) => 40, - itemBuilder: (context, index) { - final message = messageList[index]; - final nextMessage = - index < messageList.length - 1 ? messageList[index + 1] : null; - final isLastInGroup = - nextMessage == null || - nextMessage.senderId != message.senderId || - nextMessage.createdAt - .difference(message.createdAt) - .inMinutes - .abs() > - 3; + Widget chatMessageListWidget( + List messageList, + ) => SuperListView.builder( + listController: listController, + padding: EdgeInsets.only( + top: 16, + bottom: 80 + MediaQuery.of(context).padding.bottom, + ), + controller: scrollController, + reverse: true, // Show newest messages at the bottom + itemCount: messageList.length, + findChildIndexCallback: (key) { + if (key is! ValueKey) return null; + final messageId = key.value.substring(messageKeyPrefix.length); + final index = messageList.indexWhere((m) => m.id == messageId); + // Return null for invalid indices to let SuperListView handle it properly + return index >= 0 ? index : null; + }, + extentEstimation: (_, _) => 40, + itemBuilder: (context, index) { + final message = messageList[index]; + final nextMessage = + index < messageList.length - 1 ? messageList[index + 1] : null; + final isLastInGroup = + nextMessage == null || + nextMessage.senderId != message.senderId || + nextMessage.createdAt + .difference(message.createdAt) + .inMinutes + .abs() > + 3; - final key = ValueKey('$messageKeyPrefix${message.id}'); + final key = ValueKey('$messageKeyPrefix${message.id}'); - return chatIdentity.when( - skipError: true, - data: - (identity) => MessageItem( - key: key, - message: message, - isCurrentUser: identity?.id == message.senderId, - onAction: (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(); - } - }, - onJump: (messageId) { - final messageIndex = messageList.indexWhere( - (m) => m.id == messageId, - ); - 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 chatIdentity.when( + skipError: true, + data: + (identity) => MessageItem( + key: key, + message: message, + isCurrentUser: identity?.id == message.senderId, + onAction: (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(); + } + }, + onJump: (messageId) { + final messageIndex = messageList.indexWhere( + (m) => m.id == messageId, + ); + 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, + ); }); - return; + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.union({messageId})); } - 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, - ), - loading: - () => MessageItem( - key: key, - message: message, - isCurrentUser: false, - onAction: null, - progress: null, - showAvatar: false, - onJump: (_) {}, - ), - error: (_, _) => SizedBox.shrink(key: key), - ); - }, + }); + 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, + ), + loading: + () => MessageItem( + key: key, + message: message, + isCurrentUser: false, + onAction: null, + progress: null, + showAvatar: false, + onJump: (_) {}, + ), + error: (_, _) => SizedBox.shrink(key: key), ); + }, + ); return AppScaffold( appBar: AppBar( diff --git a/lib/screens/chat/search_messages.dart b/lib/screens/chat/search_messages.dart index 1b0c6ce4..bc29e988 100644 --- a/lib/screens/chat/search_messages.dart +++ b/lib/screens/chat/search_messages.dart @@ -4,10 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/chat/messages_notifier.dart'; +import 'package:island/pods/chat/chat_rooms.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +import 'dart:async'; // Class to represent the result when popping from search messages class SearchMessagesResult { @@ -15,6 +17,9 @@ class SearchMessagesResult { const SearchMessagesResult(this.messageId); } +// Search states for better UX +enum SearchState { idle, searching, results, noResults, error } + class SearchMessagesScreen extends HookConsumerWidget { final String roomId; @@ -25,118 +30,316 @@ class SearchMessagesScreen extends HookConsumerWidget { final searchController = useTextEditingController(); final withLinks = useState(false); final withAttachments = useState(false); + final searchState = useState(SearchState.idle); + final searchResultCount = useState(null); + + // Debounce timer for search optimization + final debounceTimer = useRef(null); final messagesNotifier = ref.read( messagesNotifierProvider(roomId).notifier, ); final messages = ref.watch(messagesNotifierProvider(roomId)); + // Optimized search function with debouncing + void performSearch(String query) { + if (query.trim().isEmpty) { + searchState.value = SearchState.idle; + searchResultCount.value = null; + messagesNotifier.clearSearch(); + return; + } + + searchState.value = SearchState.searching; + + // 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, + ); + }); + } + + // 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]); + useEffect(() { // Clear search when screen is disposed return () { + debounceTimer.value?.cancel(); messagesNotifier.clearSearch(); + // Clear flashing messages when leaving search + ref.read(flashingMessagesProvider.notifier).state = {}; }; }, []); return AppScaffold( - appBar: AppBar(title: const Text('searchMessages').tr()), + appBar: AppBar( + title: const Text('searchMessages').tr(), + bottom: + searchState.value == SearchState.searching + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator(), + ) + : null, + ), body: Column( children: [ - Column( - children: [ - TextField( - controller: searchController, - decoration: InputDecoration( - hintText: 'searchMessagesHint'.tr(), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only( - left: 16, - right: 16, - top: 12, - bottom: 16, - ), - suffix: IconButton( - iconSize: 18, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.clear), - onPressed: () { - searchController.clear(); - messagesNotifier.clearSearch(); - }, - ), - ), - onChanged: (query) { - messagesNotifier.searchMessages( - query, - withLinks: withLinks.value, - withAttachments: withAttachments.value, - ); - }, + // Search input section + Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(8), ), - Row( - children: [ - Expanded( - child: CheckboxListTile( - secondary: const Icon(Symbols.link), - title: const Text('searchLinks').tr(), - value: withLinks.value, - onChanged: (bool? value) { - withLinks.value = value!; - messagesNotifier.searchMessages( - searchController.text, - withLinks: withLinks.value, - withAttachments: withAttachments.value, - ); - }, + ), + child: Column( + children: [ + TextField( + controller: searchController, + autofocus: true, + decoration: InputDecoration( + hintText: 'searchMessagesHint'.tr(), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 16, ), - ), - Expanded( - child: CheckboxListTile( - secondary: const Icon(Symbols.file_copy), - title: const Text('searchAttachments').tr(), - value: withAttachments.value, - onChanged: (bool? value) { - withAttachments.value = value!; - messagesNotifier.searchMessages( - searchController.text, - withLinks: withLinks.value, - withAttachments: withAttachments.value, - ); - }, - ), - ), - ], - ), - ], - ), - const Divider(height: 1), - Expanded( - child: messages.when( - data: - (messageList) => - messageList.isEmpty - ? Center(child: Text('noMessagesFound'.tr())) - : SuperListView.builder( - padding: const EdgeInsets.symmetric(vertical: 16), - reverse: false, // Show newest messages at the top - itemCount: messageList.length, - itemBuilder: (context, index) { - final message = messageList[index]; - return MessageListTile( - message: message, - onJump: (messageId) { - // Return the search result and pop back to room detail - context.pop(SearchMessagesResult(messageId)); - }, - ); + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (searchController.text.isNotEmpty) + IconButton( + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + performSearch(''); }, ), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, _) => Center( - child: Text('errorGeneric'.tr(args: [error.toString()])), + if (searchResultCount.value != null && + searchState.value == SearchState.results) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${searchResultCount.value}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), ), + onChanged: performSearch, + ), + // Search filters + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 8, + ), + child: Row( + children: [ + Expanded( + child: FilterChip( + avatar: const Icon(Symbols.link, size: 16), + label: const Text('searchLinks').tr(), + selected: withLinks.value, + onSelected: (bool? value) { + withLinks.value = value!; + performSearch(searchController.text); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilterChip( + avatar: const Icon(Symbols.file_copy, size: 16), + label: const Text('searchAttachments').tr(), + selected: withAttachments.value, + onSelected: (bool? value) { + withAttachments.value = value!; + performSearch(searchController.text); + }, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + + // Search results section + Expanded( + child: messages.when( + data: (messageList) { + switch (searchState.value) { + case SearchState.idle: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'searchMessagesHint'.tr(), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + ], + ), + ); + + case SearchState.noResults: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'noMessagesFound'.tr(), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + const SizedBox(height: 8), + Text( + 'tryDifferentKeywords'.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + ], + ), + ); + + case SearchState.results: + return SuperListView.builder( + padding: const EdgeInsets.symmetric(vertical: 16), + reverse: false, // Show newest messages at the top + itemCount: messageList.length, + itemBuilder: (context, index) { + final message = messageList[index]; + return MessageListTile( + message: message, + onJump: (messageId) { + // Return the search result and pop back to room detail + context.pop(SearchMessagesResult(messageId)); + }, + ); + }, + ); + + default: + return const SizedBox.shrink(); + } + }, + loading: () { + if (searchState.value == SearchState.searching) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Searching...'), + ], + ), + ); + } + return const Center(child: CircularProgressIndicator()); + }, + error: (error, _) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'searchError'.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () => performSearch(searchController.text), + icon: const Icon(Icons.refresh), + label: const Text('retry').tr(), + ), + ], + ), + ); + }, ), ), ], diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index c3344bde..92ac13e2 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -298,7 +298,10 @@ class _MessageActionSheetState extends State { if (widget.remoteMessage.content?.isNotEmpty ?? false) ...[ Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Theme.of( context, @@ -335,6 +338,7 @@ class _MessageActionSheetState extends State { const Spacer(), if (_shouldShowExpandButton) IconButton( + visualDensity: VisualDensity.compact, icon: Icon( _isExpanded ? Symbols.expand_less