diff --git a/lib/src/visual/components/verification_badge.comp.dart b/lib/src/visual/components/verification_badge.comp.dart index a2cb731f..0d096caa 100644 --- a/lib/src/visual/components/verification_badge.comp.dart +++ b/lib/src/visual/components/verification_badge.comp.dart @@ -33,7 +33,7 @@ class VerificationBadgeComp extends StatefulWidget { } class _VerificationBadgeCompState extends State { - bool _isVerified = false; + late bool _isVerified = widget.contact?.verified ?? true; bool _isSharedVerified = false; int _verifiedByTransferredTrustCount = 0; int _sharedByVerifiedCount = 0; diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 10b06b38..fcde1654 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:mutex/mutex.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; @@ -88,8 +87,6 @@ class _ChatMessagesViewState extends State super.dispose(); } - Mutex protectMessageUpdating = Mutex(); - bool _isViewActive() { if (!mounted) return false; return !AppState.isAppInBackground && @@ -105,33 +102,29 @@ class _ChatMessagesViewState extends State _group = newGroup; }); - protectMessageUpdating.protect(() async { - if (groupActionsSub == null) { - final actionsStream = twonlyDB.groupsDao.watchGroupActions( - newGroup.groupId, - ); - groupActionsSub = actionsStream.listen((update) async { - groupActions = update; - await setMessages(allMessages, update); - }); + if (groupActionsSub == null) { + final actionsStream = twonlyDB.groupsDao.watchGroupActions( + newGroup.groupId, + ); + groupActionsSub = actionsStream.listen((update) async { + groupActions = update; + await setMessages(allMessages, update); + }); - final contactsStream = twonlyDB.contactsDao.watchAllContacts(); - contactSub = contactsStream.listen((contacts) { - for (final contact in contacts) { - userIdToContact[contact.userId] = contact; - } - }); - } - }); + final contactsStream = twonlyDB.contactsDao.watchAllContacts(); + contactSub = contactsStream.listen((contacts) { + for (final contact in contacts) { + userIdToContact[contact.userId] = contact; + } + }); + } }); final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId); messageSub = msgStream.listen((update) async { allMessages = update; - await protectMessageUpdating.protect(() async { - await setMessages(update, groupActions); - _hasReceivedFirstMessageBatch = true; - }); + await setMessages(update, groupActions); + _hasReceivedFirstMessageBatch = true; }); final groupContacts = await twonlyDB.groupsDao.getGroupContact( @@ -158,7 +151,7 @@ class _ChatMessagesViewState extends State List groupActions, ) async { if (_isViewActive()) { - await flutterLocalNotificationsPlugin.cancelAll(); + unawaited(flutterLocalNotificationsPlugin.cancelAll()); } for (final msg in newMessages) { @@ -222,18 +215,34 @@ class _ChatMessagesViewState extends State if (_isViewActive()) { for (final contactId in openedMessages.keys) { - await notifyContactAboutOpeningMessage( - contactId, - openedMessages[contactId]!, + unawaited( + notifyContactAboutOpeningMessage( + contactId, + openedMessages[contactId]!, + ), ); } } + final wasSentByMe = + _hasReceivedFirstMessageBatch && + newMessages.isNotEmpty && + newMessages.last.senderId == null; + if (!mounted) return; setState(() { messages = chatItems.reversed.toList(); }); + if (wasSentByMe && itemScrollController.isAttached) { + unawaited( + itemScrollController.scrollTo( + index: 0, + duration: const Duration(milliseconds: 150), + ), + ); + } + final items = await MemoryItem.convertFromMessages(storedMediaFiles); if (!mounted) return; galleryItems = items.values.toList(); @@ -326,63 +335,66 @@ class _ChatMessagesViewState extends State itemCount: messages.length + 1 + 1, itemScrollController: itemScrollController, itemBuilder: (context, i) { - if (i == 0) { - return userService.currentUser.typingIndicators - ? TypingIndicator(group: group) - : Container(); - } - i -= 1; - if (i == messages.length) { - return Padding( - padding: const EdgeInsets.only(top: 10), - child: InChatGroupOverview(group: group), - ); - } - if (messages[i].isDate) { - return ChatDateChip( - item: messages[i], - ); - } else if (messages[i].isGroupAction) { - return ChatGroupAction( - key: Key(messages[i].groupAction!.groupHistoryId), - action: messages[i].groupAction!, - ); - } else { - final chatMessage = messages[i].message!; - return BlinkWidget( - key: Key('blink_${chatMessage.messageId}'), - enabled: focusedScrollItem == i, - child: AnimatedNewMessage( - key: Key('anim_${chatMessage.messageId}'), - messageId: chatMessage.messageId, - animateIds: _animateMessageIds, - child: ChatListEntry( - key: Key(chatMessage.messageId), - message: messages[i].message!, - nextMessage: (i > 0) - ? messages[i - 1].message - : null, - prevMessage: ((i + 1) < messages.length) - ? messages[i + 1].message - : null, + if (i == 0) { + return userService.currentUser.typingIndicators + ? TypingIndicator(group: group) + : Container(); + } + i -= 1; + if (i == messages.length) { + return Padding( + key: Key('overview_${group.groupId}'), + padding: const EdgeInsets.only(top: 10), + child: InChatGroupOverview( group: group, - galleryItems: galleryItems, - userIdToContact: userIdToContact, - scrollToMessage: scrollToMessage, - onResponseTriggered: () { - setState(() { - quotesMessage = chatMessage; - }); - textFieldFocus?.requestFocus(); - }, ), - ), - ); - } - }, + ); + } + if (messages[i].isDate) { + return ChatDateChip( + item: messages[i], + ); + } else if (messages[i].isGroupAction) { + return ChatGroupAction( + key: Key(messages[i].groupAction!.groupHistoryId), + action: messages[i].groupAction!, + ); + } else { + final chatMessage = messages[i].message!; + return BlinkWidget( + key: Key('blink_${chatMessage.messageId}'), + enabled: focusedScrollItem == i, + child: AnimatedNewMessage( + key: Key('anim_${chatMessage.messageId}'), + messageId: chatMessage.messageId, + animateIds: _animateMessageIds, + child: ChatListEntry( + key: Key(chatMessage.messageId), + message: messages[i].message!, + nextMessage: (i > 0) + ? messages[i - 1].message + : null, + prevMessage: ((i + 1) < messages.length) + ? messages[i + 1].message + : null, + group: group, + galleryItems: galleryItems, + userIdToContact: userIdToContact, + scrollToMessage: scrollToMessage, + onResponseTriggered: () { + setState(() { + quotesMessage = chatMessage; + }); + textFieldFocus?.requestFocus(); + }, + ), + ), + ); + } + }, + ), ), ), - ), if (quotesMessage != null) Container( padding: const EdgeInsets.only( diff --git a/lib/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart b/lib/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart index 1749f47c..d44f4e78 100644 --- a/lib/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart +++ b/lib/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart @@ -23,7 +23,10 @@ class InChatGroupOverview extends StatefulWidget { State createState() => _InChatGroupOverviewState(); } -class _InChatGroupOverviewState extends State { +class _InChatGroupOverviewState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; Contact? _directContact; StreamSubscription? _verificationSub; StreamSubscription>? _transferredTrustSub; @@ -113,6 +116,7 @@ class _InChatGroupOverviewState extends State { @override Widget build(BuildContext context) { + super.build(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), child: Center( diff --git a/test/services/messages_stream_test.dart b/test/services/messages_stream_test.dart new file mode 100644 index 00000000..3fefeeca --- /dev/null +++ b/test/services/messages_stream_test.dart @@ -0,0 +1,148 @@ +import 'package:drift/drift.dart' hide isNotNull, isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/json/userdata.model.dart'; +import 'package:twonly/src/services/user.service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await locator.reset(); + locator + ..registerSingleton( + TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ), + ) + ..registerSingleton(UserService()); + + userService.currentUser = UserData( + userId: 1, + username: 'test_user', + displayName: 'Test User', + subscriptionPlan: 'Free', + currentSetupPage: null, + appVersion: 100, + ); + userService.isUserCreated = true; + }); + + tearDown(() async { + await twonlyDB.close(); + }); + + test( + 'watchByGroupId stream emits updates when a new text message is inserted', + () async { + // 1. Create a group + await twonlyDB.groupsDao.createNewGroup( + GroupsCompanion.insert( + groupId: 'test_group_stream', + groupName: 'Test Group Stream', + ), + ); + + // 2. Start watching + final stream = await twonlyDB.messagesDao.watchByGroupId( + 'test_group_stream', + ); + + final emissions = >[]; + final subscription = stream.listen(emissions.add); + + // Wait briefly for initial empty emission + await Future.delayed(const Duration(milliseconds: 100)); + + expect(emissions, hasLength(1)); + expect(emissions.first, isEmpty); + + // 3. Insert a message + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_1', + groupId: 'test_group_stream', + type: 'text', + content: const Value('Hello world'), + ), + ); + + // Wait for the stream to propagate the event + await Future.delayed(const Duration(milliseconds: 100)); + + // Verify it emitted the new list containing our message + expect(emissions, hasLength(2)); + expect(emissions[1], hasLength(1)); + expect(emissions[1].first.messageId, equals('msg_1')); + + await subscription.cancel(); + }, + ); + + test( + 'simulate sending flow with group updates and verify messages stream emits', + () async { + await twonlyDB.groupsDao.createNewGroup( + GroupsCompanion.insert( + groupId: 'test_group_2', + groupName: 'Test Group 2', + ), + ); + + final msgStream = await twonlyDB.messagesDao.watchByGroupId( + 'test_group_2', + ); + final messagesEmissions = >[]; + final msgSub = msgStream.listen(messagesEmissions.add); + + final groupStream = twonlyDB.groupsDao.watchGroup('test_group_2'); + final groupEmissions = []; + final groupSub = groupStream.listen(groupEmissions.add); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(messagesEmissions, hasLength(1)); + expect(groupEmissions, hasLength(1)); + + // Simulate sending: + // 1. Update group draftMessage to null (as done in insertAndSendTextMessage) + await twonlyDB.groupsDao.updateGroup( + 'test_group_2', + const GroupsCompanion( + draftMessage: Value(null), + ), + ); + + // 2. Insert message + final message = await twonlyDB.messagesDao.insertMessage( + MessagesCompanion( + groupId: const Value('test_group_2'), + content: const Value('Hello from simulation'), + type: Value(MessageType.text.name), + ), + ); + + expect(message, isNotNull); + + await Future.delayed(const Duration(milliseconds: 100)); + + // Verify group stream received updates + expect(groupEmissions.length, greaterThan(1)); + + // Verify message stream received updates containing the new message + expect(messagesEmissions.length, greaterThan(1)); + expect( + messagesEmissions.last.any((m) => m.messageId == message!.messageId), + isTrue, + ); + + await msgSub.cancel(); + await groupSub.cancel(); + }, + ); +}