From a6cb13dcf961582e06fc3d411f59a2babc61a079 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 26 Jul 2026 12:55:47 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=87=D1=82=D0=BE=20=D1=82=D0=B5=D0=B1=D1=8F=20=D0=BF=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=BE=D0=B2=D0=B0=D0=BB=D0=B8=20=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chat_parsing.dart | 4 + lib/backend/modules/chats.dart | 43 +++++- lib/core/storage/app_database.dart | 11 +- .../screens/chats/chat_list_screen.dart | 54 ++++--- test/chat_mention_badge_test.dart | 135 ++++++++++++++++++ test/mention_test.dart | 32 ++--- test/message_bubble_layout_test.dart | 8 +- test/text_entities_test.dart | 22 +-- test/text_entity_render_test.dart | 14 +- 9 files changed, 260 insertions(+), 63 deletions(-) create mode 100644 test/chat_mention_badge_test.dart diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index 06c0ee7..e5472f7 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -44,6 +44,9 @@ CachedChat? parseChatRow( final presence = _resolvePresence(type, otherId, presenceMap); final adminsOwner = _resolveAdmins(chat); final pinned = _resolvePinnedMessage(chat['pinnedMessage']); + final mentionId = int.tryParse( + chat['lastMentionMessageId']?.toString() ?? '', + ); return CachedChat( id: id, @@ -71,6 +74,7 @@ CachedChat? parseChatRow( pinnedMsgText: pinned.text, pinnedMsgTime: pinned.time, pinnedMsgIsPreview: pinned.isPreview, + lastMentionMsgId: mentionId ?? existing[id]?.lastMentionMsgId, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 936e04a..3dd98bc 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -37,6 +37,20 @@ Map parseParticipants(dynamic raw) { return {}; } +/// Server message ids carry their timestamp in the high bits: the low 16 bits +/// are an intra-millisecond sequence number. +int messageIdToTime(int messageId) => messageId >> 16; + +bool messageMentionsUser(Map message, int userId) { + final elements = message['elements']; + if (elements is! List) return false; + for (final element in elements.whereType()) { + if (element['type']?.toString() != 'USER_MENTION') continue; + if (element['entityId'] == userId) return true; + } + return false; +} + class CachedChat { final int id; final int accountId; @@ -65,6 +79,7 @@ class CachedChat { final String? pinnedMsgText; final int? pinnedMsgTime; final bool pinnedMsgIsPreview; + final int? lastMentionMsgId; CachedChat({ required this.id, @@ -93,6 +108,7 @@ class CachedChat { this.pinnedMsgText, this.pinnedMsgTime, this.pinnedMsgIsPreview = false, + this.lastMentionMsgId, }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') ? lastMsgText.replaceAll('\n', ' ') : lastMsgText; @@ -137,6 +153,12 @@ class CachedChat { bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder; + bool get hasUnreadMention { + final mentionId = lastMentionMsgId; + if (mentionId == null || mentionId <= 0) return false; + return messageIdToTime(mentionId) > (participants[accountId] ?? 0); + } + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -164,6 +186,7 @@ class CachedChat { pinnedMsgText: row['pinned_msg_text'] as String?, pinnedMsgTime: row['pinned_msg_time'] as int?, pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1, + lastMentionMsgId: row['last_mention_msg_id'] as int?, ); static Set _decodeOptions(dynamic raw) { @@ -209,6 +232,7 @@ class CachedChat { 'pinned_msg_text': pinnedMsgText, 'pinned_msg_time': pinnedMsgTime, 'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0, + 'last_mention_msg_id': lastMentionMsgId, }; static const Object _keep = Object(); @@ -238,6 +262,7 @@ class CachedChat { Object? pinnedMsgText = _keep, Object? pinnedMsgTime = _keep, bool? pinnedMsgIsPreview, + Object? lastMentionMsgId = _keep, }) { return CachedChat( id: id, @@ -284,6 +309,9 @@ class CachedChat { ? this.pinnedMsgTime : pinnedMsgTime as int?, pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview, + lastMentionMsgId: identical(lastMentionMsgId, _keep) + ? this.lastMentionMsgId + : lastMentionMsgId as int?, ); } } @@ -435,8 +463,12 @@ class ChatsModule { } catch (_) {} } - row['unread_count'] = 0; - await AppDatabase.saveChats([row]); + final cached = CachedChat.fromDbRow(row); + final currentMark = cached.participants[accountId] ?? 0; + final participants = Map.from(cached.participants) + ..[accountId] = mark > currentMark ? mark : currentMark; + final updated = cached.copyWith(unreadCount: 0, participants: participants); + await AppDatabase.saveChats([updated.toDbRow()]); _bump(); } @@ -818,6 +850,13 @@ class ChatsModule { } if (unread != null) newRow['unread_count'] = unread; + if (msgIdInt != null && + status != 'REMOVED' && + senderId != accountId && + messageMentionsUser(msg, accountId)) { + newRow['last_mention_msg_id'] = msgIdInt; + } + final pinned = _extractPinnedMessage(msg); if (pinned != null) { newRow['pinned_msg_id'] = pinned.id; diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index d14bbfa..333c22d 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -221,7 +221,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 19, + version: 20, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -333,6 +333,14 @@ class AppDatabase { 'INTEGER NOT NULL DEFAULT 0', ); } + if (oldVersion < 20) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_mention_msg_id', + 'INTEGER', + ); + } }, ); } @@ -485,6 +493,7 @@ class AppDatabase { pinned_msg_text TEXT, pinned_msg_time INTEGER, pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, + last_mention_msg_id INTEGER, PRIMARY KEY (id, account_id) ) '''; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index bbdd752..1645c8c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1633,6 +1633,7 @@ class _ChatListScreenState extends State avatar ?? "", presenceUserId: secondId, unreadCount: chat.unreadCount, + hasMention: chat.hasUnreadMention, isMuted: chat.isMuted, isVerified: isVerified, isPinned: isPinned, @@ -1691,6 +1692,7 @@ class _ChatListScreenState extends State ? chat.iconUrl! : '', unreadCount: chat.unreadCount, + hasMention: chat.hasUnreadMention, isMuted: chat.isMuted, isVerified: chat.isOfficial, isPinned: isPinned, @@ -2508,6 +2510,27 @@ class _ChatListScreenState extends State ); } + Widget _countBadge(ColorScheme cs, String label, {required bool muted}) { + return Container( + constraints: const BoxConstraints(minWidth: 20), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: muted ? cs.surfaceContainerHighest : cs.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: muted ? cs.outline : cs.onPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ); + } + Widget _buildChatItem( String id, String name, @@ -2517,6 +2540,7 @@ class _ChatListScreenState extends State int presenceUserId = 0, bool isRead = false, int unreadCount = 0, + bool hasMention = false, bool isMuted = false, bool isVerified = false, bool isPinned = false, @@ -2737,29 +2761,15 @@ class _ChatListScreenState extends State ), ?statusIcon, const SizedBox(width: 8), + if (hasMention) ...[ + _countBadge(cs, '@', muted: isMuted), + const SizedBox(width: 4), + ], if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - unreadCount.toString(), - style: TextStyle( - color: isMuted - ? cs.outline - : cs.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.1, - ), - ), + _countBadge( + cs, + unreadCount.toString(), + muted: isMuted, ) else if (isRead) Icon( diff --git a/test/chat_mention_badge_test.dart b/test/chat_mention_badge_test.dart new file mode 100644 index 0000000..0cc3611 --- /dev/null +++ b/test/chat_mention_badge_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_parsing.dart'; +import 'package:komet/backend/modules/chats.dart'; + +const int _me = 4242; +const int _peer = 7331; +const int _chatId = -1000000000001; + +const int _readMark = 1700000000000; +const int _mentionTime = 1700000005000; +const int _lastMsgTime = 1700000010000; + +const int _mentionId = 111411200327680777; +const int _lastMsgId = 111411200655360012; +const int _oldMentionId = 111411196067840005; + +CachedChat _chat({ + int? mentionId, + int readMark = _readMark, + int unread = 2, +}) => CachedChat( + id: _chatId, + accountId: _me, + type: 'CHAT', + unreadCount: unread, + lastEventTime: _lastMsgTime, + cachedAt: 0, + dontDisturbUntil: 0, + isOnline: false, + seenTime: 0, + participants: {_me: readMark, _peer: _lastMsgTime}, + lastMentionMsgId: mentionId, +); + +void main() { + group('message id', () { + test('carries the timestamp in its high bits', () { + expect(messageIdToTime(_mentionId), _mentionTime); + expect(messageIdToTime(_lastMsgId), _lastMsgTime); + expect(messageIdToTime(_oldMentionId), _readMark - 60000); + }); + }); + + group('hasUnreadMention', () { + test('is set when the mention is newer than my read mark', () { + expect(_chat(mentionId: _mentionId).hasUnreadMention, isTrue); + }); + + test('stays off for a mention I have already read', () { + expect(_chat(mentionId: _oldMentionId).hasUnreadMention, isFalse); + }); + + test('clears once the read mark passes the mention', () { + final read = _chat( + mentionId: _mentionId, + readMark: _lastMsgTime, + unread: 0, + ); + expect(read.hasUnreadMention, isFalse); + }); + + test('is false without a mention id', () { + expect(_chat().hasUnreadMention, isFalse); + }); + }); + + group('parseChatRow', () { + CachedChat parse(Map chat) => parseChatRow( + chat, + _me, + _me, + const {}, + const {}, + const {}, + const {}, + 0, + )!; + + test('reads lastMentionMessageId from the server chat', () { + final chat = parse({ + 'id': _chatId, + 'type': 'CHAT', + 'title': 'test mention', + 'newMessages': 2, + 'lastEventTime': _lastMsgTime, + 'participants': {'$_me': _readMark}, + 'lastMentionMessageId': '$_mentionId', + }); + + expect(chat.lastMentionMsgId, _mentionId); + expect(chat.hasUnreadMention, isTrue); + }); + + test('a chat without mentions keeps the badge off', () { + final chat = parse({ + 'id': _chatId, + 'type': 'CHAT', + 'title': 'test', + 'lastEventTime': _lastMsgTime, + 'participants': {'$_me': _readMark}, + }); + + expect(chat.lastMentionMsgId, isNull); + expect(chat.hasUnreadMention, isFalse); + }); + }); + + group('messageMentionsUser', () { + test('matches a USER_MENTION addressed to me', () { + expect( + messageMentionsUser(const { + 'elements': [ + {'type': 'USER_MENTION', 'entityId': _me, 'length': 15}, + ], + }, _me), + isTrue, + ); + }); + + test('ignores a mention of somebody else', () { + expect( + messageMentionsUser(const { + 'elements': [ + {'type': 'USER_MENTION', 'entityId': _peer, 'length': 15}, + ], + }, _me), + isFalse, + ); + }); + + test('ignores messages without elements', () { + expect(messageMentionsUser(const {'text': 'privet'}, _me), isFalse); + }); + }); +} diff --git a/test/mention_test.dart b/test/mention_test.dart index a122855..a586b53 100644 --- a/test/mention_test.dart +++ b/test/mention_test.dart @@ -44,8 +44,8 @@ void main() { ); final query = mentionQueryAt(c.text, 3)!; c.insertMention( - userId: 3079465, - name: 'Алексей Поляков', + userId: 555001, + name: 'Пётр Синицын', start: query.start, end: query.end, ); @@ -55,9 +55,9 @@ void main() { ); final content = c.buildContent(); - expect(content.text, 'Алексей Поляков test'); + expect(content.text, 'Пётр Синицын test'); expect(content.elements, [ - {'type': 'USER_MENTION', 'from': 0, 'length': 15, 'entityId': 3079465}, + {'type': 'USER_MENTION', 'from': 0, 'length': 12, 'entityId': 555001}, ]); }); @@ -139,21 +139,21 @@ void main() { test('a non-contact falls back to the ONEME name', () { final contact = info([ { - 'name': 'Алексей', - 'firstName': 'Алексей', - 'lastName': 'Поляков', + 'name': 'Пётр', + 'firstName': 'Пётр', + 'lastName': 'Синицын', 'type': 'ONEME', }, ]); - expect(contact.fullName, 'Алексей Поляков'); + expect(contact.fullName, 'Пётр Синицын'); expect(contact.isSavedContact, isFalse); }); test('a custom name wins over the oneme one', () { final contact = info([ {'firstName': 'Лёша', 'lastName': 'сосед', 'type': 'CUSTOM'}, - {'firstName': 'Алексей', 'lastName': 'Поляков', 'type': 'ONEME'}, + {'firstName': 'Пётр', 'lastName': 'Синицын', 'type': 'ONEME'}, ]); expect(contact.fullName, 'Лёша сосед'); @@ -163,24 +163,24 @@ void main() { group('parseFormatElements', () { test('reads a server USER_MENTION without an explicit from', () { final ranges = parseFormatElements([ - {'entityId': 3079465, 'type': 'USER_MENTION', 'length': 15}, + {'entityId': 555001, 'type': 'USER_MENTION', 'length': 12}, ]); expect(ranges.single.format, TextFormat.userMention); expect(ranges.single.start, 0); - expect(ranges.single.length, 15); - expect(ranges.single.entityId, 3079465); + expect(ranges.single.length, 12); + expect(ranges.single.entityId, 555001); }); test('segmentizeFormats carries the mention id onto its segment', () { - final segments = segmentizeFormats('Алексей Поляков test', const [ + final segments = segmentizeFormats('Пётр Синицын test', const [ FormatRange( format: TextFormat.userMention, start: 0, - length: 15, - entityId: 3079465, + length: 12, + entityId: 555001, ), ]); - expect(segments.first.mentionId, 3079465); + expect(segments.first.mentionId, 555001); expect(segments.last.mentionId, isNull); }); }); diff --git a/test/message_bubble_layout_test.dart b/test/message_bubble_layout_test.dart index d4ea2b7..5f470d9 100644 --- a/test/message_bubble_layout_test.dart +++ b/test/message_bubble_layout_test.dart @@ -26,7 +26,7 @@ CachedMessage _message({ 'message': { 'id': '9', 'sender': _me, - 'text': 'Алексей Поляков написал очень длинный ответ', + 'text': 'Пётр Синицын написал очень длинный ответ', 'time': 0, 'attaches': [], }, @@ -68,14 +68,14 @@ Rect _rectOf(WidgetTester tester, Finder finder) { } void main() { - setUp(() => ContactCache.put(_peer, 'Алексей Поляков123')); + setUp(() => ContactCache.put(_peer, 'Пётр Синицын')); testWidgets('a long sender name pushes the clock to the bubble edge', ( tester, ) async { await _pumpBubble(tester, _message(text: 'нет')); - final header = _rectOf(tester, find.text('Алексей Поляков123')); + final header = _rectOf(tester, find.text('Пётр Синицын')); final clock = _rectOf(tester, find.textContaining('05:46')); final body = _rectOf(tester, find.text('нет')); @@ -88,7 +88,7 @@ void main() { ) async { await _pumpBubble(tester, _message(text: 'нет', withReply: true)); - final header = _rectOf(tester, find.text('Алексей Поляков123')); + final header = _rectOf(tester, find.text('Пётр Синицын')); final label = _rectOf(tester, find.text('Вы')); final quote = _rectOf( tester, diff --git a/test/text_entities_test.dart b/test/text_entities_test.dart index 366e3c5..8aa4eb2 100644 --- a/test/text_entities_test.dart +++ b/test/text_entities_test.dart @@ -4,27 +4,27 @@ import 'package:komet/core/utils/text_entities.dart'; void main() { group('detectTextEntities', () { test('finds a phone and a card in one message', () { - final found = detectTextEntities('+79231234567 тест 2200123456789019'); + final found = detectTextEntities('+70001234567 тест 2200123456789019'); expect(found, hasLength(2)); expect(found.first.kind, TextEntityKind.phone); - expect(found.first.value, '+79231234567'); + expect(found.first.value, '+70001234567'); expect(found.last.kind, TextEntityKind.card); expect(found.last.value, '2200123456789019'); }); test('finds a bare russian phone and a spaced card', () { - final found = detectTextEntities('89231234567 и 2200 1234 5678 9019'); + final found = detectTextEntities('80001234567 и 2200 1234 5678 9019'); expect(found.map((e) => e.kind), [ TextEntityKind.phone, TextEntityKind.card, ]); - expect(found.first.value, '+89231234567'); + expect(found.first.value, '+80001234567'); expect(found.last.value, '2200123456789019'); }); test('ignores digits that are not a valid card', () { - expect(detectTextEntities('116984447620359334'), isEmpty); + expect(detectTextEntities('111411200327680777'), isEmpty); expect(detectTextEntities('2200123456789018'), isEmpty); expect(detectTextEntities('1234567890123456'), isEmpty); }); @@ -34,23 +34,23 @@ void main() { }); test('finds a nickname but not an email', () { - final found = detectTextEntities('привет @GroupGuardBot и mail@ya.ru'); + final found = detectTextEntities('привет @ExampleBot и mail@ya.ru'); expect(found, hasLength(1)); expect(found.single.kind, TextEntityKind.mention); - expect(found.single.value, 'GroupGuardBot'); + expect(found.single.value, 'ExampleBot'); expect(found.single.start, 7); - expect(found.single.end, 21); + expect(found.single.end, 18); }); test('finds a formatted profile phone', () { - final found = detectTextEntities('+7 (923) 123-45-67'); + final found = detectTextEntities('+7 (000) 123-45-67'); expect(found, hasLength(1)); expect(found.single.kind, TextEntityKind.phone); - expect(found.single.value, '+79231234567'); + expect(found.single.value, '+70001234567'); }); test('skips ranges that are already claimed', () { - const text = 'https://max.ru/GroupGuardBot'; + const text = 'https://max.ru/ExampleBot'; expect( detectTextEntities(text, skip: [(start: 0, end: text.length)]), isEmpty, diff --git a/test/text_entity_render_test.dart b/test/text_entity_render_test.dart index 4e79a9a..092bd1f 100644 --- a/test/text_entity_render_test.dart +++ b/test/text_entity_render_test.dart @@ -7,7 +7,7 @@ import 'package:komet/frontend/widgets/message_bubble.dart'; import 'package:komet/frontend/widgets/text_entity_actions.dart'; import 'package:komet/l10n/app_localizations.dart'; -const String _sample = '+79231234567 тест 2200123456789019 @GroupGuardBot'; +const String _sample = '+70001234567 тест 2200123456789019 @ExampleBot'; CachedMessage _message(String text) => CachedMessage( id: '1', @@ -67,9 +67,9 @@ void main() { ); final accent = ThemeData().colorScheme.primary; - final phone = _spanWithText(tester, '+79231234567'); + final phone = _spanWithText(tester, '+70001234567'); final card = _spanWithText(tester, '2200123456789019'); - final mention = _spanWithText(tester, '@GroupGuardBot'); + final mention = _spanWithText(tester, '@ExampleBot'); final plain = _spanWithText(tester, ' тест '); expect(phone?.style?.color, accent); @@ -90,12 +90,12 @@ void main() { accountId: 1, chatId: 2, senderId: 1, - text: '@GroupGuardBot test', + text: '@ExampleBot test', time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, status: 'sent', payload: const { 'elements': [ - {'entityName': 'GroupGuardBot', 'type': 'USER_MENTION', 'length': 14}, + {'entityName': 'ExampleBot', 'type': 'USER_MENTION', 'length': 11}, ], }, ); @@ -105,7 +105,7 @@ void main() { MessageBubble(message: message, isMe: false, myId: 1, chatType: 'DIALOG'), ); - final mention = _spanWithText(tester, '@GroupGuardBot'); + final mention = _spanWithText(tester, '@ExampleBot'); expect(mention?.style?.color, ThemeData().colorScheme.primary); expect(mention?.recognizer, isA()); }); @@ -122,7 +122,7 @@ void main() { ); expect( - _spanWithText(tester, '+79231234567')?.recognizer, + _spanWithText(tester, '+70001234567')?.recognizer, isA(), ); expect(