💄 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",
"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",

View File

@@ -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<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({
int offset = 0,
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();
_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 = <LocalChatMessage>[];
final seenIds = <String>{};
@@ -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',
);

View File

@@ -385,121 +385,121 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
Widget chatMessageListWidget(List<LocalChatMessage> 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<LocalChatMessage> 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<String>) 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(

View File

@@ -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<int?>(null);
// Debounce timer for search optimization
final debounceTimer = useRef<Timer?>(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(),
),
],
),
);
},
),
),
],

View File

@@ -298,7 +298,10 @@ class _MessageActionSheetState extends State<MessageActionSheet> {
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<MessageActionSheet> {
const Spacer(),
if (_shouldShowExpandButton)
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
_isExpanded
? Symbols.expand_less