forked from eatvio/eatvio_app
refactor(chat): message dedup
This commit is contained in:
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||
// recipient-relative и для второго устройства отправителя всегда false,
|
||||
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||
// только для обратной совместимости парсинга.
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
|
||||
@@ -144,6 +144,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
bool wasChanged = false;
|
||||
@@ -151,10 +154,18 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||
if (chatRoom.id == message.roomId) {
|
||||
wasChanged = true;
|
||||
if (chatRoom.unreadMessagesCount == 0) {
|
||||
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||
final isDuplicate = message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||
// Собственное сообщение (второе устройство автора) обновляет
|
||||
// lastMessage, но не создаёт непрочитанное (S3).
|
||||
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||
willIncrementUnreadChats = true;
|
||||
}
|
||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
||||
return chatRoom.copyWith(
|
||||
lastMessage: message,
|
||||
unreadMessagesCount: isOwn || isDuplicate ? chatRoom.unreadMessagesCount : chatRoom.unreadMessagesCount + 1,
|
||||
);
|
||||
}
|
||||
return chatRoom;
|
||||
}).toList();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
@@ -43,6 +44,39 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
ChatRoom buildChatRoom(String chatId, {int unread = 0}) {
|
||||
return ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: const Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: unread,
|
||||
chosenPlan: null,
|
||||
);
|
||||
}
|
||||
|
||||
ChannelReadEvent socketEvent({required int id, required int authorId, required String roomId}) {
|
||||
return _FakeChannelReadEvent(jsonEncode(<String, dynamic>{
|
||||
'id': id,
|
||||
'data': 'socket message',
|
||||
'media_data': null,
|
||||
'author_id': authorId,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
'read_by_to_user': false,
|
||||
'is_author_message_auth_user': false,
|
||||
'room_id': roomId,
|
||||
}));
|
||||
}
|
||||
|
||||
Future<StreamController<ChannelReadEvent>> connectToSocketStream() async {
|
||||
final controller = StreamController<ChannelReadEvent>.broadcast();
|
||||
when(mockWsManager.newMessagesStream).thenAnswer((_) => controller.stream);
|
||||
await chatsCubit.connectToWebSocket();
|
||||
return controller;
|
||||
}
|
||||
|
||||
test('начальное состояние - initial', () {
|
||||
expect(chatsCubit.state, isA<ChatsState>());
|
||||
expect(chatsCubit.state.state, BaseState.initial);
|
||||
@@ -180,6 +214,76 @@ void main() {
|
||||
verify(mockWsManager.newMessagesStream).called(1);
|
||||
});
|
||||
|
||||
test('_handleNewMessageViaSocket - собственное сообщение обновляет lastMessage без непрочитанного', () async {
|
||||
const chatId = 'chat-id';
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
|
||||
when(mockWsManager.userId).thenReturn(42);
|
||||
|
||||
final controller = await connectToSocketStream();
|
||||
controller.add(socketEvent(id: 100, authorId: 42, roomId: chatId));
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(room.lastMessage?.id, 100);
|
||||
expect(room.unreadMessagesCount, 0);
|
||||
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 0);
|
||||
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('_handleNewMessageViaSocket - чужое сообщение увеличивает непрочитанное', () async {
|
||||
const chatId = 'chat-id';
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
|
||||
when(mockWsManager.userId).thenReturn(42);
|
||||
|
||||
final controller = await connectToSocketStream();
|
||||
controller.add(socketEvent(id: 200, authorId: 7, roomId: chatId));
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(room.lastMessage?.id, 200);
|
||||
expect(room.unreadMessagesCount, 1);
|
||||
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
|
||||
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('_handleNewMessageViaSocket - повторная доставка того же id не двоит непрочитанное', () async {
|
||||
const chatId = 'chat-id';
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
|
||||
when(mockWsManager.userId).thenReturn(42);
|
||||
|
||||
final controller = await connectToSocketStream();
|
||||
controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId));
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId));
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(room.unreadMessagesCount, 1);
|
||||
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
|
||||
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('_handleNewMessageViaSocket - чужое сообщение в непрочитанном чате не поднимает счётчик чатов', () async {
|
||||
const chatId = 'chat-id';
|
||||
chatsCubit.emit(
|
||||
ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId, unread: 2)], chatsWithUnreadMessagesCount: 1),
|
||||
);
|
||||
when(mockWsManager.userId).thenReturn(42);
|
||||
|
||||
final controller = await connectToSocketStream();
|
||||
controller.add(socketEvent(id: 400, authorId: 7, roomId: chatId));
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(room.unreadMessagesCount, 3);
|
||||
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
|
||||
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('close - закрывает cubit и отписывается от подписок', () async {
|
||||
final mockSubscription1 = MockStreamSubscription();
|
||||
|
||||
@@ -476,3 +580,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Облегчённый фейк события канала: ChatsCubit использует только event.data,
|
||||
/// поэтому реальный ChannelReadEvent (требует Channel) не нужен.
|
||||
class _FakeChannelReadEvent extends Fake implements ChannelReadEvent {
|
||||
_FakeChannelReadEvent(this.data);
|
||||
|
||||
@override
|
||||
final dynamic data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user