diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index 27b81f50..1bdfa20c 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -252,22 +252,36 @@ class ChatRoomCubit extends Cubit { } /// Добавляет входящее сообщение в текущий список, не затирая более новый state. + /// + /// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства + /// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется + /// в открытый чат, но не считается входящим: звук не играет, а список чатов + /// обновляет S3 через `new-chat-messages.{senderId}`. + /// + /// Возвращает `true`, только если нужно проиграть звук входящего (чужое + /// сообщение, реально добавленное). bool addIncomingMessage(ChatMessageModel message) { - final currentUserId = wsManager.userId; - if (currentUserId != null && message.authorId == currentUserId) { - return false; - } - final currentState = state; if (currentState is! ChatRoomStateLoaded) { return false; } - if (currentState.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) { + + final currentUserId = wsManager.userId; + final isOwn = currentUserId != null && message.authorId == currentUserId; + + // Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е. + // это эхо уже добавленного оптимистичного сообщения). + final alreadyExists = currentState.chatMessages.any( + (existing) => + (message.id != 0 && existing.id == message.id) || + (message.uuid != null && existing.uuid == message.uuid), + ); + if (alreadyExists) { return false; } final added = _emitLoaded((current) { - if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) { + if (current.chatMessages.any((existing) => message.id != 0 && existing.id == message.id)) { return current; } return current.copyWith( @@ -278,8 +292,13 @@ class ChatRoomCubit extends Cubit { if (!added) { return false; } - chatRepository.emitNewMessageToChats(message); - return true; + + // Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}). + if (!isOwn) { + chatRepository.emitNewMessageToChats(message); + } + + return !isOwn; } Future _subscribeToPresenceChannel({required String chatId}) async { diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index 38793443..9c1f9b5e 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -1469,11 +1469,13 @@ void main() { roomId: 'chat-id', ); - chatRoomCubit.addIncomingMessage(incoming); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage(incoming); final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isTrue); expect(state.chatMessages.first.id, 99); expect(state.chatMessages.length, 2); + verify(mockChatRepository.emitNewMessageToChats(incoming)).called(1); }); test('addIncomingMessage - не добавляет дубликат по id', () async { @@ -1501,14 +1503,63 @@ void main() { roomId: 'chat-id', ); - chatRoomCubit.addIncomingMessage(incoming); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage(incoming); final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isFalse); expect(state.chatMessages.length, 1); expect(state.chatMessages.first.data, 'Test message'); + verifyNever(mockChatRepository.emitNewMessageToChats(any)); }); - test('addIncomingMessage - игнорирует собственные сообщения', () async { + test('addIncomingMessage - не добавляет дубликат по uuid (эхо оптимистичного сообщения)', () async { + const uuid = 'room-uuid'; + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 501, + data: 'Echo with client uuid', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + uuid: 'client-uuid-1', + ), + ); + + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 502, + data: 'Same client uuid, server id differs', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + uuid: 'client-uuid-1', + ), + ); + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isFalse); + expect(state.chatMessages.where((message) => message.uuid == 'client-uuid-1').length, 1); + }); + + test('addIncomingMessage - добавляет собственное сообщение (эхо второго устройства) без звука и без обновления списка', () async { const uuid = 'room-uuid'; when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); @@ -1523,7 +1574,7 @@ void main() { await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); - chatRoomCubit.addIncomingMessage( + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( ChatMessageModel( id: 77, data: 'Own echo', @@ -1531,14 +1582,36 @@ void main() { authorId: 1, updatedAt: DateTime.now(), isRead: true, - isCurrentUserAuthor: true, + isCurrentUserAuthor: false, roomId: 'chat-id', ), ); final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.chatMessages.length, 1); - expect(state.chatMessages.any((message) => message.id == 77), isFalse); + expect(shouldPlayIncomingSound, isFalse); + expect(state.chatMessages.length, 2); + expect(state.chatMessages.any((message) => message.id == 77), isTrue); + // Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}). + verifyNever(mockChatRepository.emitNewMessageToChats(any)); + }); + + test('addIncomingMessage - игнорирует событие, когда state не loaded', () { + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 42, + data: 'Arrived before chat is loaded', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ), + ); + + expect(shouldPlayIncomingSound, isFalse); + expect(chatRoomCubit.state, isA()); + verifyNever(mockChatRepository.emitNewMessageToChats(any)); }); test('updateChosenPlanState - не затирает сообщения, пришедшие во время запроса', () async {