💄 Bug fixes search messages and optimization

This commit is contained in:
2025-10-09 01:11:14 +08:00
parent a129b9cdd0
commit 1d9361c12f
5 changed files with 469 additions and 208 deletions

View File

@@ -263,7 +263,6 @@
"walletCurrencyShortPoints": "NSP", "walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "The Solar Dollars", "walletCurrencyGolds": "The Solar Dollars",
"walletCurrencyShortGolds": "NSD", "walletCurrencyShortGolds": "NSD",
"retry": "Retry",
"creatorHubUnselectedHint": "Pick / create a publisher to get started.", "creatorHubUnselectedHint": "Pick / create a publisher to get started.",
"relationships": "Relationships", "relationships": "Relationships",
"addFriend": "Send a Friend Request", "addFriend": "Send a Friend Request",
@@ -1020,6 +1019,10 @@
"searchLinks": "Links", "searchLinks": "Links",
"searchAttachments": "Attachments", "searchAttachments": "Attachments",
"noMessagesFound": "No messages found", "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", "openInBrowser": "Open in Browser",
"highlightPost": "Highlight Post", "highlightPost": "Highlight Post",
"filters": "Filters", "filters": "Filters",

View File

@@ -39,6 +39,7 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false; bool _isJumping = false;
bool _isUpdatingState = false;
DateTime? _lastPauseTime; DateTime? _lastPauseTime;
@override @override
@@ -92,6 +93,28 @@ class MessagesNotifier extends _$MessagesNotifier {
return messages; return messages;
} }
Future<void> _updateStateSafely(List<LocalChatMessage> 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 = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in sortedMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
state = AsyncValue.data(uniqueMessages);
} finally {
_isUpdatingState = false;
}
}
Future<List<LocalChatMessage>> _getCachedMessages({ Future<List<LocalChatMessage>> _getCachedMessages({
int offset = 0, int offset = 0,
int take = 20, int take = 20,
@@ -668,11 +691,37 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { Future<void> searchMessages(
String query, {
bool? withLinks,
bool? withAttachments,
}) async {
_searchQuery = query.trim(); _searchQuery = query.trim();
_withLinks = withLinks; _withLinks = withLinks;
_withAttachments = withAttachments; _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() { void clearSearch() {
@@ -717,6 +766,9 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
_isJumping = true; _isJumping = true;
// Clear flashing messages when starting a new jump
ref.read(flashingMessagesProvider.notifier).state = {};
try { try {
talker.log('Fetching message $messageId'); talker.log('Fetching message $messageId');
final message = await fetchMessageById(messageId); final message = await fetchMessageById(messageId);
@@ -772,7 +824,7 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
if (newMessages.isNotEmpty) { if (newMessages.isNotEmpty) {
// Merge with current messages // Merge with current messages more safely
final allMessages = [...currentMessages, ...newMessages]; final allMessages = [...currentMessages, ...newMessages];
final uniqueMessages = <LocalChatMessage>[]; final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{}; final seenIds = <String>{};
@@ -781,8 +833,7 @@ class MessagesNotifier extends _$MessagesNotifier {
uniqueMessages.add(message); uniqueMessages.add(message);
} }
} }
_sortMessages(uniqueMessages); await _updateStateSafely(uniqueMessages);
state = AsyncValue.data(uniqueMessages);
talker.log( talker.log(
'Updated state with ${uniqueMessages.length} total messages', 'Updated state with ${uniqueMessages.length} total messages',
); );

View File

@@ -385,8 +385,9 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
} }
Widget chatMessageListWidget(List<LocalChatMessage> messageList) => Widget chatMessageListWidget(
SuperListView.builder( List<LocalChatMessage> messageList,
) => SuperListView.builder(
listController: listController, listController: listController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
@@ -396,11 +397,11 @@ class ChatRoomScreen extends HookConsumerWidget {
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
findChildIndexCallback: (key) { findChildIndexCallback: (key) {
final valueKey = key as ValueKey; if (key is! ValueKey<String>) return null;
final messageId = (valueKey.value as String).substring( final messageId = key.value.substring(messageKeyPrefix.length);
messageKeyPrefix.length, final index = messageList.indexWhere((m) => m.id == messageId);
); // Return null for invalid indices to let SuperListView handle it properly
return messageList.indexWhere((m) => m.id == messageId); return index >= 0 ? index : null;
}, },
extentEstimation: (_, _) => 40, extentEstimation: (_, _) => 40,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -474,8 +475,7 @@ class ChatRoomScreen extends HookConsumerWidget {
scrollController: scrollController, scrollController: scrollController,
alignment: 0.5, alignment: 0.5,
duration: duration:
(estimatedDistance) => (estimatedDistance) => Duration(milliseconds: 250),
Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut, curve: (estimatedDistance) => Curves.easeInOut,
); );
}); });

View File

@@ -4,10 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/messages_notifier.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/app_scaffold.dart';
import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:island/widgets/chat/message_list_tile.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'dart:async';
// Class to represent the result when popping from search messages // Class to represent the result when popping from search messages
class SearchMessagesResult { class SearchMessagesResult {
@@ -15,6 +17,9 @@ class SearchMessagesResult {
const SearchMessagesResult(this.messageId); const SearchMessagesResult(this.messageId);
} }
// Search states for better UX
enum SearchState { idle, searching, results, noResults, error }
class SearchMessagesScreen extends HookConsumerWidget { class SearchMessagesScreen extends HookConsumerWidget {
final String roomId; final String roomId;
@@ -25,99 +30,255 @@ class SearchMessagesScreen extends HookConsumerWidget {
final searchController = useTextEditingController(); final searchController = useTextEditingController();
final withLinks = useState(false); final withLinks = useState(false);
final withAttachments = useState(false); final withAttachments = useState(false);
final searchState = useState(SearchState.idle);
final searchResultCount = useState<int?>(null);
// Debounce timer for search optimization
final debounceTimer = useRef<Timer?>(null);
final messagesNotifier = ref.read( final messagesNotifier = ref.read(
messagesNotifierProvider(roomId).notifier, messagesNotifierProvider(roomId).notifier,
); );
final messages = ref.watch(messagesNotifierProvider(roomId)); 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(() { useEffect(() {
// Clear search when screen is disposed // Clear search when screen is disposed
return () { return () {
debounceTimer.value?.cancel();
messagesNotifier.clearSearch(); messagesNotifier.clearSearch();
// Clear flashing messages when leaving search
ref.read(flashingMessagesProvider.notifier).state = {};
}; };
}, []); }, []);
return AppScaffold( 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( body: Column(
children: [ children: [
Column( // Search input section
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(8),
),
),
child: Column(
children: [ children: [
TextField( TextField(
controller: searchController, controller: searchController,
autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(), hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.only( contentPadding: const EdgeInsets.only(
left: 16, left: 16,
right: 16, right: 16,
top: 12, top: 12,
bottom: 16, bottom: 16,
), ),
suffix: IconButton( prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18, iconSize: 18,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
searchController.clear(); searchController.clear();
messagesNotifier.clearSearch(); performSearch('');
}, },
), ),
if (searchResultCount.value != null &&
searchState.value == SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
), ),
onChanged: (query) { decoration: BoxDecoration(
messagesNotifier.searchMessages( color: Theme.of(
query, context,
withLinks: withLinks.value, ).colorScheme.primary.withOpacity(0.1),
withAttachments: withAttachments.value, borderRadius: BorderRadius.circular(12),
);
},
), ),
Row( 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: [ children: [
Expanded( Expanded(
child: CheckboxListTile( child: FilterChip(
secondary: const Icon(Symbols.link), avatar: const Icon(Symbols.link, size: 16),
title: const Text('searchLinks').tr(), label: const Text('searchLinks').tr(),
value: withLinks.value, selected: withLinks.value,
onChanged: (bool? value) { onSelected: (bool? value) {
withLinks.value = value!; withLinks.value = value!;
messagesNotifier.searchMessages( performSearch(searchController.text);
searchController.text,
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
}, },
), ),
), ),
const SizedBox(width: 8),
Expanded( Expanded(
child: CheckboxListTile( child: FilterChip(
secondary: const Icon(Symbols.file_copy), avatar: const Icon(Symbols.file_copy, size: 16),
title: const Text('searchAttachments').tr(), label: const Text('searchAttachments').tr(),
value: withAttachments.value, selected: withAttachments.value,
onChanged: (bool? value) { onSelected: (bool? value) {
withAttachments.value = value!; withAttachments.value = value!;
messagesNotifier.searchMessages( performSearch(searchController.text);
searchController.text,
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
}, },
), ),
), ),
], ],
), ),
),
], ],
), ),
),
const Divider(height: 1), const Divider(height: 1),
// Search results section
Expanded( Expanded(
child: messages.when( child: messages.when(
data: data: (messageList) {
(messageList) => switch (searchState.value) {
messageList.isEmpty case SearchState.idle:
? Center(child: Text('noMessagesFound'.tr())) return Center(
: SuperListView.builder( 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), padding: const EdgeInsets.symmetric(vertical: 16),
reverse: false, // Show newest messages at the top reverse: false, // Show newest messages at the top
itemCount: messageList.length, itemCount: messageList.length,
@@ -131,12 +292,54 @@ class SearchMessagesScreen extends HookConsumerWidget {
}, },
); );
}, },
);
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...'),
],
), ),
loading: () => const Center(child: CircularProgressIndicator()), );
error: }
(error, _) => Center( return const Center(child: CircularProgressIndicator());
child: Text('errorGeneric'.tr(args: [error.toString()])), },
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(),
),
],
),
);
},
), ),
), ),
], ],

View File

@@ -298,7 +298,10 @@ class _MessageActionSheetState extends State<MessageActionSheet> {
if (widget.remoteMessage.content?.isNotEmpty ?? false) ...[ if (widget.remoteMessage.content?.isNotEmpty ?? false) ...[
Container( Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
@@ -335,6 +338,7 @@ class _MessageActionSheetState extends State<MessageActionSheet> {
const Spacer(), const Spacer(),
if (_shouldShowExpandButton) if (_shouldShowExpandButton)
IconButton( IconButton(
visualDensity: VisualDensity.compact,
icon: Icon( icon: Icon(
_isExpanded _isExpanded
? Symbols.expand_less ? Symbols.expand_less