diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index a6d826e..14f1af8 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -177,6 +177,22 @@ class ChatSearchHit { }); } +class MessageSearchHit { + final int chatId; + final String? messageId; + final String? text; + final int time; + final int senderId; + + const MessageSearchHit({ + required this.chatId, + this.messageId, + this.text, + required this.time, + required this.senderId, + }); +} + sealed class MessageEvent { final int chatId; const MessageEvent(this.chatId); @@ -1192,7 +1208,28 @@ class ChatsModule { return hits; } - static Future> searchChats( + static List _parseMessageResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final message = item['message']; + if (message is! Map) continue; + final chatId = item['chatId']; + if (chatId is! int || chatId == 0) continue; + hits.add(MessageSearchHit( + chatId: chatId, + messageId: message['id']?.toString(), + text: message['text'] as String?, + time: (message['time'] as int?) ?? 0, + senderId: (message['sender'] as int?) ?? 0, + )); + } + return hits; + } + + static Future> searchMessages( Api api, String query, { int count = 50, @@ -1205,9 +1242,9 @@ class ChatsModule { 'query': term, }); if (packet.isError) return const []; - return _parseSearchResult(packet.payload); + return _parseMessageResult(packet.payload); } catch (e) { - logger.w('searchChats failed: $e'); + logger.w('searchMessages failed: $e'); return const []; } } @@ -1233,6 +1270,39 @@ class ChatsModule { } } + static Future subscribeChat( + Api api, + int chatId, { + bool subscribe = true, + }) async { + try { + await api.sendRequest(Opcode.chatSubscribe, { + 'chatId': chatId, + 'subscribe': subscribe, + }); + } catch (e) { + logger.w('subscribeChat failed: $e'); + } + } + + static Future ensureChatCached( + Api api, + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) return true; + try { + final info = await getChatInfo(api, chatId); + if (info == null) return false; + await cacheServerChat(info, accountId); + return true; + } catch (e) { + logger.w('ensureChatCached failed for $chatId: $e'); + return false; + } + } + static Future createGroupChat( Api api, { required String title, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 37740af..68ddc71 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -601,24 +601,21 @@ class AppDatabase { ); } - static Future>> searchMessages( + static Future>> searchChatsByTitle( int accountId, String query, { - int limit = 50, + int limit = 30, }) async { final term = query.trim(); if (term.isEmpty) return const []; final db = await _instance; final like = '%${_escapeLike(term)}%'; - return db.rawQuery( - 'SELECT m.id AS id, m.chat_id AS chat_id, m.sender_id AS sender_id, ' - 'm.text AS text, m.time AS time, ' - 'c.title AS chat_title, c.icon_url AS chat_icon, c.type AS chat_type ' - 'FROM messages m ' - 'LEFT JOIN chats_cache c ON c.id = m.chat_id AND c.account_id = m.account_id ' - "WHERE m.account_id = ? AND m.deleted = 0 AND m.text LIKE ? ESCAPE '\\' " - 'ORDER BY m.time DESC LIMIT ?', - [accountId, like, limit], + return db.query( + 'chats_cache', + where: "account_id = ? AND title LIKE ? ESCAPE '\\'", + whereArgs: [accountId, like], + orderBy: 'last_event_time DESC', + limit: limit, ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 26b7353..0da0204 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -409,6 +409,11 @@ class _ChatScreenState extends State } try { + final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId); + if (cachedRows.isEmpty) { + await ChatsModule.ensureChatCached(api, _myId, widget.chatId); + await ChatsModule.subscribeChat(api, widget.chatId); + } final serverMessages = await messagesModule.fetchHistory( _myId, widget.chatId, diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index 82edd00..e042a18 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -29,8 +29,9 @@ class _SearchScreenState extends State { bool _loading = false; PhoneLookupResult? _phoneResult; List> _contacts = const []; - List _chats = const []; - List> _messages = const []; + List> _chats = const []; + List _messages = const []; + Map> _msgChatMeta = const {}; List _public = const []; @override @@ -62,10 +63,14 @@ class _SearchScreenState extends State { _contacts = const []; _chats = const []; _messages = const []; + _msgChatMeta = const {}; _public = const []; }); return; } + if (_phoneResult != null) { + setState(() => _phoneResult = null); + } _debounce = Timer(const Duration(milliseconds: 300), _runSearch); } @@ -81,10 +86,10 @@ class _SearchScreenState extends State { accountId == null ? Future.value(const >[]) : AppDatabase.searchContacts(accountId, query), - ChatsModule.searchChats(api, query), accountId == null ? Future.value(const >[]) - : AppDatabase.searchMessages(accountId, query), + : AppDatabase.searchChatsByTitle(accountId, query), + ChatsModule.searchMessages(api, query), ChatsModule.searchPublic(api, query), phoneQuery == null ? Future.value(null) @@ -93,17 +98,27 @@ class _SearchScreenState extends State { if (!mounted || token != _seq) return; - final chats = results[1] as List; - final chatIds = chats.map((c) => c.id).toSet(); + final chats = results[1] as List>; + final messages = results[2] as List; + final localChatIds = chats.map((c) => c['id'] as int).toSet(); final public = (results[3] as List) - .where((c) => !chatIds.contains(c.id)) + .where((c) => !localChatIds.contains(c.id)) .toList(); + var meta = >{}; + if (accountId != null && messages.isNotEmpty) { + final ids = messages.map((m) => m.chatId).toSet().toList(); + final rows = await AppDatabase.loadChatsByIds(accountId, ids); + meta = {for (final r in rows) r['id'] as int: r}; + if (!mounted || token != _seq) return; + } + setState(() { _phoneResult = results[4] as PhoneLookupResult?; _contacts = results[0] as List>; _chats = chats; - _messages = results[2] as List>; + _messages = messages; + _msgChatMeta = meta; _public = public; _loading = false; }); @@ -246,22 +261,21 @@ class _SearchScreenState extends State { ], if (_chats.isNotEmpty) ...[ _sectionHeader(cs, 'Чаты'), - for (final hit in _chats) _chatTile(hit), + for (final row in _chats) + _ResultTile( + name: (row['title'] as String?) ?? '', + imageUrl: row['icon_url'] as String?, + onTap: () => _openChat( + row['id'] as int, + (row['title'] as String?) ?? '', + row['icon_url'] as String?, + (row['type'] as String?) ?? 'CHAT', + ), + ), ], if (_messages.isNotEmpty) ...[ _sectionHeader(cs, 'Сообщения'), - for (final row in _messages) - _ResultTile( - name: (row['chat_title'] as String?) ?? '', - imageUrl: row['chat_icon'] as String?, - subtitle: (row['text'] as String?)?.trim(), - onTap: () => _openChat( - row['chat_id'] as int, - (row['chat_title'] as String?) ?? '', - row['chat_icon'] as String?, - (row['chat_type'] as String?) ?? 'CHAT', - ), - ), + for (final hit in _messages) _messageTile(hit), ], if (_public.isNotEmpty) ...[ _sectionHeader(cs, 'Глобальный поиск'), @@ -279,6 +293,19 @@ class _SearchScreenState extends State { onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), ); + Widget _messageTile(MessageSearchHit hit) { + final meta = _msgChatMeta[hit.chatId]; + final title = (meta?['title'] as String?) ?? 'Чат'; + final icon = meta?['icon_url'] as String?; + final type = (meta?['type'] as String?) ?? 'CHAT'; + return _ResultTile( + name: title, + imageUrl: icon, + subtitle: hit.text?.trim(), + onTap: () => _openChat(hit.chatId, title, icon, type), + ); + } + Widget _sectionHeader(ColorScheme cs, String title) => Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 6), child: Text(