Search messages!

♻️ Optimize messages loading, syncing
This commit is contained in:
2025-08-26 01:05:30 +08:00
parent 596d212593
commit b48a1aac44
7 changed files with 280 additions and 29 deletions

View File

@@ -40,6 +40,8 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- file_saver (0.0.1):
- Flutter
- Firebase/CoreOnly (12.0.0):
- FirebaseCore (~> 12.0.0)
- Firebase/Crashlytics (12.0.0):
@@ -303,6 +305,7 @@ DEPENDENCIES:
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
@@ -381,6 +384,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
file_saver:
:path: ".symlinks/plugins/file_saver/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
@@ -464,6 +469,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: 800d487043c0557d9faed71477a38d9aafb08a41
firebase_analytics: cd56fc56f75c1df30a6ff5290cd56e230996a76d
firebase_core: 633e1851ffe1b9ab875f6467a4f574c79cef02e4

View File

@@ -68,6 +68,30 @@ class AppDatabase extends _$AppDatabase {
return (delete(chatMessages)..where((m) => m.id.equals(id))).go();
}
Future<List<LocalChatMessage>> searchMessages(
String roomId,
String query,
) async {
var selectStatement = select(chatMessages)
..where((m) => m.roomId.equals(roomId));
if (query.isNotEmpty) {
selectStatement =
selectStatement
..where((m) => m.content.like('%${query.toLowerCase()}%'));
}
final messages =
await (selectStatement
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
.get();
return messages.map((msg) => companionToMessage(msg)).toList();
}
// Convert between Drift and model objects
ChatMessagesCompanion messageToCompanion(LocalChatMessage message) {
return ChatMessagesCompanion(

View File

@@ -8,7 +8,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

View File

@@ -38,6 +38,7 @@ import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/chat/search_messages_screen.dart';
import 'package:island/screens/creators/hub.dart';
import 'package:island/screens/creators/posts/post_manage_list.dart';
import 'package:island/screens/creators/stickers/stickers.dart';
@@ -555,6 +556,14 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatDetailScreen(id: id);
},
),
GoRoute(
name: 'searchMessages',
path: '/chat/:id/search',
builder: (context, state) {
final id = state.pathParameters['id']!;
return SearchMessagesScreen(roomId: id);
},
),
],
),

View File

@@ -283,6 +283,9 @@ class MessagesNotifier extends _$MessagesNotifier {
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
int? _totalCount;
String? _searchQuery;
bool? _withLinks;
bool? _withAttachments;
late final String _roomId;
int _currentPage = 0;
@@ -326,7 +329,13 @@ class MessagesNotifier extends _$MessagesNotifier {
});
}
return await loadInitial();
loadInitial();
return [];
}
List<LocalChatMessage> _sortMessages(List<LocalChatMessage> messages) {
messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return messages;
}
Future<List<LocalChatMessage>> _getCachedMessages({
@@ -337,13 +346,29 @@ class MessagesNotifier extends _$MessagesNotifier {
'Getting cached messages from offset $offset, take $take',
name: 'MessagesNotifier',
);
final dbMessages = await _database.getMessagesForRoom(
_roomId,
offset: offset,
limit: take,
);
final dbLocalMessages =
dbMessages.map(_database.companionToMessage).toList();
final List<LocalChatMessage> dbMessages;
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
dbMessages = await _database.searchMessages(_roomId, _searchQuery ?? '');
} else {
final chatMessagesFromDb = await _database.getMessagesForRoom(
_roomId,
offset: offset,
limit: take,
);
dbMessages = chatMessagesFromDb.map(_database.companionToMessage).toList();
}
List<LocalChatMessage> filteredMessages = dbMessages;
if (_withLinks == true) {
filteredMessages = filteredMessages.where((msg) => _hasLink(msg)).toList();
}
if (_withAttachments == true) {
filteredMessages = filteredMessages.where((msg) => _hasAttachment(msg)).toList();
}
final dbLocalMessages = filteredMessages;
if (offset == 0) {
final pendingForRoom =
@@ -352,7 +377,7 @@ class MessagesNotifier extends _$MessagesNotifier {
.toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
_sortMessages(allMessages); // Use the helper function
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
@@ -427,7 +452,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_isSyncing = true;
developer.log('Starting message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = true;
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try {
final dbMessages = await _database.getMessagesForRoom(
_room.id,
@@ -488,7 +513,7 @@ class MessagesNotifier extends _$MessagesNotifier {
showErrorAlert(err);
} finally {
developer.log('Finished message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = false;
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = false);
_isSyncing = false;
}
}
@@ -499,7 +524,7 @@ class MessagesNotifier extends _$MessagesNotifier {
bool synced = false,
}) async {
try {
if (offset == 0 && !synced) {
if (offset == 0 && !synced && (_searchQuery == null || _searchQuery!.isEmpty)) {
_fetchAndCacheMessages(offset: 0, take: take).catchError((_) {
return <LocalChatMessage>[];
});
@@ -514,7 +539,11 @@ class MessagesNotifier extends _$MessagesNotifier {
return localMessages;
}
return await _fetchAndCacheMessages(offset: offset, take: take);
if (_searchQuery == null || _searchQuery!.isEmpty) {
return await _fetchAndCacheMessages(offset: offset, take: take);
} else {
return []; // If searching, and no local messages, don't fetch from network
}
} catch (e) {
final localMessages = await _getCachedMessages(
offset: offset,
@@ -528,13 +557,15 @@ class MessagesNotifier extends _$MessagesNotifier {
}
}
Future<List<LocalChatMessage>> loadInitial() async {
Future<void> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier');
syncMessages();
if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages();
}
final messages = await _getCachedMessages(offset: 0, take: 100);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
return messages;
state = AsyncValue.data(messages);
}
Future<void> loadMore() async {
@@ -553,7 +584,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_hasMore = false;
}
state = AsyncValue.data([...currentMessages, ...newMessages]);
state = AsyncValue.data(_sortMessages([...currentMessages, ...newMessages]));
} catch (err, stackTrace) {
developer.log(
'Error loading more messages',
@@ -778,7 +809,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
state = AsyncValue.data(_sortMessages(newMessages));
showErrorAlert(e);
}
}
@@ -838,7 +869,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (index >= 0) {
final newList = [...currentMessages];
newList[index] = updatedMessage;
state = AsyncValue.data(newList);
state = AsyncValue.data(_sortMessages(newList));
}
}
@@ -898,6 +929,20 @@ class MessagesNotifier extends _$MessagesNotifier {
}
}
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) {
_searchQuery = query.trim();
_withLinks = withLinks;
_withAttachments = withAttachments;
loadInitial();
}
void clearSearch() {
_searchQuery = null;
_withLinks = null;
_withAttachments = null;
loadInitial();
}
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log(
'Fetching message by id $messageId',
@@ -927,6 +972,18 @@ class MessagesNotifier extends _$MessagesNotifier {
rethrow;
}
}
bool _hasLink(LocalChatMessage message) {
final content = message.toRemoteMessage().content;
if (content == null) return false;
final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*');
return urlRegex.hasMatch(content);
}
bool _hasAttachment(LocalChatMessage message) {
final remoteMessage = message.toRemoteMessage();
return remoteMessage.attachments.isNotEmpty;
}
}
class ChatRoomScreen extends HookConsumerWidget {
@@ -1424,6 +1481,12 @@ class ChatRoomScreen extends HookConsumerWidget {
),
),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
context.pushNamed('searchMessages', pathParameters: {'id': id});
},
),
AudioCallButton(roomId: id),
IconButton(
icon: const Icon(Icons.more_vert),
@@ -1433,15 +1496,14 @@ class ChatRoomScreen extends HookConsumerWidget {
),
const Gap(8),
],
bottom:
isSyncing
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(
borderRadius: BorderRadius.zero,
),
)
: null,
bottom: isSyncing
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(
borderRadius: BorderRadius.zero,
),
)
: null,
),
body: Stack(
children: [

View File

@@ -359,6 +359,17 @@ class ChatDetailScreen extends HookConsumerWidget {
: const Text('chatBreakNone').tr(),
onTap: () => showChatBreakDialog(),
),
ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
leading: const Icon(Icons.search),
trailing: const Icon(Symbols.chevron_right),
title: const Text('Search Messages').tr(),
onTap: () {
context.pushNamed('searchMessages', pathParameters: {'id': id});
},
),
],
),
error: (_, _) => const SizedBox.shrink(),

View File

@@ -0,0 +1,139 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/message_item.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class SearchMessagesScreen extends HookConsumerWidget {
final String roomId;
const SearchMessagesScreen({super.key, required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final withLinks = useState(false);
final withAttachments = useState(false);
final messagesNotifier = ref.read(
messagesNotifierProvider(roomId).notifier,
);
final messages = ref.watch(messagesNotifierProvider(roomId));
useEffect(() {
// Clear search when screen is disposed
return () {
messagesNotifier.clearSearch();
};
}, []);
return AppScaffold(
appBar: AppBar(title: const Text('Search Messages')),
body: Column(
children: [
Column(
children: [
TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Search messages...',
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,
);
},
),
Row(
children: [
Expanded(
child: CheckboxListTile(
secondary: const Icon(Symbols.link),
title: const Text('Links'),
value: withLinks.value,
onChanged: (bool? value) {
withLinks.value = value!;
messagesNotifier.searchMessages(
searchController.text,
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
},
),
),
Expanded(
child: CheckboxListTile(
secondary: const Icon(Symbols.file_copy),
title: const Text('Attachments'),
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('No messages found'.tr()))
: SuperListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16),
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
itemBuilder: (context, index) {
final message = messageList[index];
// Simplified MessageItem for search results, no grouping logic
return MessageItem(
message: message,
isCurrentUser:
false, // Or determine based on actual user
onAction: null,
onJump: (_) {},
progress: null,
showAvatar: true,
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
),
),
],
),
);
}
}