diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a9fe56004..b341b186d 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,6 +8,7 @@ ✅ Added - Added support for `Channel.messageCount` field. +- Added support for `user.messages.deleted` event. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 17f1641df..72560c96b 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -2209,6 +2209,8 @@ class ChannelClientState { /* End of reminder events */ + _listenUserMessagesDeleted(); + _startCleaningStaleTypingEvents(); _startCleaningStalePinnedMessages(); @@ -2933,156 +2935,20 @@ class ChannelClientState { } /// Updates the [message] in the state if it exists. Adds it otherwise. - void updateMessage(Message message) { - // Determine if the message should be displayed in the channel view. - if (message.parentId == null || message.showInChannel == true) { - // Create a new list of messages to avoid modifying the original - // list directly. - var newMessages = [...messages]; - final oldIndex = newMessages.indexWhere((m) => m.id == message.id); - - if (oldIndex != -1) { - // If the message already exists, prepare it for update. - final oldMessage = newMessages[oldIndex]; - var updatedMessage = message.syncWith(oldMessage); - - // Preserve quotedMessage if the update doesn't include a new - // quotedMessage. - if (message.quotedMessageId != null && - message.quotedMessage == null && - oldMessage.quotedMessage != null) { - updatedMessage = updatedMessage.copyWith( - quotedMessage: oldMessage.quotedMessage, - ); - } - - // Update the message in the list. - newMessages[oldIndex] = updatedMessage; - - // Update quotedMessage references in all messages. - newMessages = newMessages.map((it) { - // Skip if the current message does not quote the updated message. - if (it.quotedMessageId != message.id) return it; - - // Update the quotedMessage only if the updatedMessage indicates - // deletion. - if (message.isDeleted) { - return it.copyWith( - quotedMessage: updatedMessage.copyWith( - type: message.type, - deletedAt: message.deletedAt, - ), - ); - } - return it; - }).toList(); - } else { - // If the message is new, add it to the list. - newMessages.add(message); - } - - // Handle updates to pinned messages. - final newPinnedMessages = _updatePinnedMessages(message); - - // Calculate the new last message at time. - var lastMessageAt = _channelState.channel?.lastMessageAt; - lastMessageAt ??= message.createdAt; - if (_shouldUpdateChannelLastMessageAt(message)) { - lastMessageAt = [lastMessageAt, message.createdAt].max; - } - - // Apply the updated lists to the channel state. - _channelState = _channelState.copyWith( - messages: newMessages.sorted(_sortByCreatedAt), - pinnedMessages: newPinnedMessages, - channel: _channelState.channel?.copyWith( - lastMessageAt: lastMessageAt, - ), - ); - } - - // If the message is part of a thread, update thread information. - if (message.parentId case final parentId?) { - updateThreadInfo(parentId, [message]); - } - } + void updateMessage(Message message) => _updateMessages([message]); /// Cleans up all the stale error messages which requires no action. void cleanUpStaleErrorMessages() { final errorMessages = messages.where((message) { return message.isError && !message.isBounced; - }); + }).toList(); if (errorMessages.isEmpty) return; - return errorMessages.forEach(removeMessage); - } - - /// Updates the list of pinned messages based on the current message's - /// pinned status. - List _updatePinnedMessages(Message message) { - final newPinnedMessages = [...pinnedMessages]; - final oldPinnedIndex = - newPinnedMessages.indexWhere((m) => m.id == message.id); - - if (message.pinned) { - // If the message is pinned, add or update it in the list of pinned - // messages. - if (oldPinnedIndex != -1) { - newPinnedMessages[oldPinnedIndex] = message; - } else { - newPinnedMessages.add(message); - } - } else { - // If the message is not pinned, remove it from the list of pinned - // messages. - newPinnedMessages.removeWhere((m) => m.id == message.id); - } - - return newPinnedMessages; + return _removeMessages(errorMessages); } /// Remove a [message] from this [channelState]. - void removeMessage(Message message) async { - await _channel._client.chatPersistenceClient?.deleteMessageById(message.id); - - final parentId = message.parentId; - // i.e. it's a thread message, Remove it - if (parentId != null) { - final newThreads = {...threads}; - // Early return in case the thread is not available - if (!newThreads.containsKey(parentId)) return; - - // Remove thread message shown in thread page. - newThreads.update( - parentId, - (messages) => [...messages.where((e) => e.id != message.id)], - ); - - _threads = newThreads; - - // Early return if the thread message is not shown in channel. - if (message.showInChannel == false) return; - } - - // Remove regular message, thread message shown in channel - var updatedMessages = [...messages]..removeWhere((e) => e.id == message.id); - - // Remove quoted message reference from every message if available. - updatedMessages = [...updatedMessages].map((it) { - // Early return if the message doesn't have a quoted message. - if (it.quotedMessageId != message.id) return it; - - // Setting it to null will remove the quoted message from the message. - return it.copyWith( - quotedMessage: null, - quotedMessageId: null, - ); - }).toList(); - - _channelState = _channelState.copyWith( - messages: updatedMessages, - ); - } + void removeMessage(Message message) => _removeMessages([message]); /// Removes/Updates the [message] based on the [hardDelete] value. void deleteMessage(Message message, {bool hardDelete = false}) { @@ -3336,13 +3202,10 @@ class ChannelClientState { /// Update channelState with updated information. void updateChannelState(ChannelState updatedState) { final _existingStateMessages = [...messages]; - final newMessages = [ - ..._existingStateMessages.merge( - updatedState.messages, - key: (message) => message.id, - update: (original, updated) => updated.syncWith(original), - ), - ].sorted(_sortByCreatedAt); + final newMessages = _mergeMessagesIntoExisting( + existing: _existingStateMessages, + toMerge: updatedState.messages ?? [], + ).sorted(_sortByCreatedAt); final _existingStateWatchers = [...?_channelState.watchers]; final newWatchers = [ @@ -3440,19 +3303,18 @@ class ChannelClientState { /// Update threads with updated information about messages. void updateThreadInfo(String parentId, List messages) { - final newThreads = {...threads}..update( - parentId, - (original) => [ - ...original.merge( - messages, - key: (message) => message.id, - update: (original, updated) => updated.syncWith(original), - ), - ].sorted(_sortByCreatedAt), - ifAbsent: () => messages.sorted(_sortByCreatedAt), - ); + final updatedThreads = {...threads}; - _threads = newThreads; + final threadMessages = [...?updatedThreads[parentId]]; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, + ).sorted(_sortByCreatedAt); + + // Update the thread with the modified message list. + updatedThreads[parentId] = updatedThreadMessages; + + _threads = updatedThreads; } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3585,6 +3447,264 @@ class ChannelClientState { ); } + void _deleteMessagesFromUser({ + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) { + final userMessages = {}; + for (final message in [...messages, ...threads.values.flattened]) { + if (message.user?.id != userId) continue; + userMessages[message.id] = message.copyWith( + type: MessageType.deleted, + deletedAt: deletedAt ?? DateTime.now(), + state: MessageState.deleted(hard: hardDelete), + ); + } + + final messagesToDelete = userMessages.values.toList(); + return _deleteMessages(messagesToDelete, hardDelete: hardDelete); + } + + void _deleteMessages( + List messages, { + bool hardDelete = false, + }) { + if (messages.isEmpty) return; + + if (hardDelete) return _removeMessages(messages); + return _updateMessages(messages); + } + + void _updateMessages(List messages) { + if (messages.isEmpty) return; + + _updateThreadMessages(messages); + _updateChannelMessages(messages); + } + + void _updateThreadMessages(List messages) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = [...?updatedThreads[thread]]; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, + ).sorted(_sortByCreatedAt); + + // Update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages; + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _updateChannelMessages(List messages) { + if (messages.isEmpty) return; + + final affectedMessages = [ + ...messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls, + ]; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _mergeMessagesIntoExisting( + existing: channelMessages, + toMerge: affectedMessages, + ).sorted(_sortByCreatedAt); + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _mergeMessagesIntoExisting( + existing: pinnedMessages, + toMerge: affectedMessages, + ).where(_pinIsValid).sorted(_sortByCreatedAt); + + // Calculate the new last message at time. + var lastMessageAt = _channelState.channel?.lastMessageAt; + for (final message in affectedMessages) { + if (_shouldUpdateChannelLastMessageAt(message)) { + lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; + } + } + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages, + pinnedMessages: updatedPinnedMessages, + channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), + ); + } + + List _mergeMessagesIntoExisting({ + required List existing, + required List toMerge, + }) { + if (toMerge.isEmpty) return existing; + + final mergedMessages = existing.merge( + toMerge, + key: (message) => message.id, + update: (original, updated) { + var merged = updated.syncWith(original); + + // Preserve quotedMessage if the updated doesn't include it. + if (updated.quotedMessageId != null && updated.quotedMessage == null) { + merged = merged.copyWith(quotedMessage: original.quotedMessage); + } + + return merged; + }, + ); + + final toUpdateMap = {for (final m in toMerge) m.id: m}; + final updatedMessages = [ + ...mergedMessages.map((it) { + // Continue if the message doesn't quote any of the updated messages. + if (!toUpdateMap.containsKey(it.quotedMessageId)) return it; + + final updatedQuotedMessage = toUpdateMap[it.quotedMessageId]; + // Update the quotedMessage reference in the message. + return it.copyWith(quotedMessage: updatedQuotedMessage); + }) + ]; + + return updatedMessages; + } + + void _removeMessages(List messages) { + if (messages.isEmpty) return; + + final messageIds = messages.map((m) => m.id).toSet().toList(); + final persistenceClient = _channel.client.chatPersistenceClient; + // Remove the messages from the persistence client. + persistenceClient?.deleteMessageByIds(messageIds); + persistenceClient?.deletePinnedMessageByIds(messageIds); + + _removeThreadMessages(messages); + _removeChannelMessages(messages); + } + + void _removeThreadMessages(List messages) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = updatedThreads[thread]; + // Continue if the thread doesn't exist. + if (threadMessages == null) continue; + + // Remove the deleted message from the thread messages and reference from + // other messages quoting it. + final updatedThreadMessages = _removeMessagesFromExisting( + existing: threadMessages, + toRemove: messages, + ); + + // If there are no more messages in the thread, remove the thread entry. + if (updatedThreadMessages.isEmpty) { + updatedThreads.remove(thread); + continue; + } + + // Otherwise, update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages; + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _removeChannelMessages(List messages) { + if (messages.isEmpty) return; + + final affectedMessages = [ + ...messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls, + ]; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _removeMessagesFromExisting( + existing: channelMessages, + toRemove: affectedMessages, + ); + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _removeMessagesFromExisting( + existing: pinnedMessages, + toRemove: affectedMessages, + ); + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages, + pinnedMessages: updatedPinnedMessages, + ); + } + + List _removeMessagesFromExisting({ + required List existing, + required List toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedMessages = [ + ...existing.where((it) => !toRemoveIds.contains(it.id)).map((it) { + // Continue if the message doesn't quote any of the deleted messages. + if (!toRemoveIds.contains(it.quotedMessageId)) return it; + + // Setting it to null will remove the quoted message from the message. + return it.copyWith(quotedMessageId: null, quotedMessage: null); + }), + ]; + + return updatedMessages; + } + + // Listens to user message deleted events and marks messages from that user + // as either soft or hard deleted based on the event data. + void _listenUserMessagesDeleted() { + _subscriptions.add( + _channel.on(EventType.userMessagesDeleted).listen((event) { + final user = event.user; + if (user == null) return; + + return _deleteMessagesFromUser( + userId: user.id, + hardDelete: event.hardDelete ?? false, + deletedAt: event.createdAt, + ); + }), + ); + } + /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelThreads.cancel(); @@ -3601,8 +3721,15 @@ class ChannelClientState { } bool _pinIsValid(Message message) { - final now = DateTime.now(); - return message.pinExpires!.isAfter(now); + // If the message is not pinned, it's not valid. + if (message.pinned != true) return false; + + // If there's no expiration, the pin is valid. + final pinExpires = message.pinExpires; + if (pinExpires == null) return true; + + // If there's an expiration, check if it's still valid. + return pinExpires.isAfter(DateTime.now()); } /// Extension methods for checking channel capabilities on a Channel instance. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 0fa074b47..205b8d508 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2177,6 +2177,8 @@ class ClientState { _listenUserUpdated(); _listenAllChannelsRead(); + + _listenUserMessagesDeleted(); } /// Stops listening to the client events. @@ -2279,6 +2281,24 @@ class ClientState { ); } + void _listenUserMessagesDeleted() { + _eventsSubscription?.add( + _client.on(EventType.userMessagesDeleted).listen((event) async { + final cid = event.cid; + // Only handle message deletions that are not channel specific + // (i.e. user banned globally from the app) + if (cid != null) return; + + // Iterate through all the available channels and send the event + // to be handled by the respective channel instances. + for (final cid in [...channels.keys]) { + final channelEvent = event.copyWith(cid: cid); + _client.handleEvent(channelEvent); + } + }), + ); + } + final StreamChatClient _client; /// Sets the user currently interacting with the client diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 63c3c3f8e..d2d24925c 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -177,4 +177,7 @@ class EventType { /// Local event sent when channel push notification preference is updated. static const String channelPushPreferenceUpdated = 'channel.push_preference.updated'; + + /// Event sent when all messages of a user are deleted. + static const String userMessagesDeleted = 'user.messages.deleted'; } diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index de680785a..895c74bef 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -5195,6 +5195,390 @@ void main() { ); }); }); + + group('User messages deleted event', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + late MockPersistenceClient persistenceClient; + + setUp(() { + persistenceClient = MockPersistenceClient(); + when(() => client.chatPersistenceClient).thenReturn(persistenceClient); + when(() => persistenceClient.deleteMessageByIds(any())) + .thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())) + .thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test( + 'should soft delete all messages from user when hardDelete is false', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.updateMessage(message1); + channel.state?.updateMessage(message2); + channel.state?.updateMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.now(); + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are soft deleted + expect(channel.state?.messages.length, equals(3)); + final deletedMessages = channel.state?.messages + .where((m) => m.user?.id == 'user-1') + .toList(); + expect(deletedMessages?.length, equals(2)); + for (final message in deletedMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = + channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + test( + 'should hard delete all messages from user when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.updateMessage(message1); + channel.state?.updateMessage(message2); + channel.state?.updateMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = + channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + test( + 'should handle thread messages from user', + () async { + // Setup: Add parent and thread messages + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + + channel.state?.updateMessage(parentMessage); + channel.state?.updateThreadInfo('parent-msg', [ + threadMessage1, + threadMessage2, + ]); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['parent-msg']?.length, equals(2)); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread messages are soft deleted + final threadMessages = channel.state?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } + + // Verify parent message is unaffected + final parent = channel.state?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); + + test( + 'should do nothing when user is null', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.updateMessage(message1); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are unaffected + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.first.type, + isNot(MessageType.deleted), + ); + }, + ); + + test( + 'should handle empty message list', + () async { + // Setup: Empty channel + expect(channel.state?.messages.length, equals(0)); + + // Create user.messages.deleted event + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, + ); + + // Dispatch event - should not throw + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify state is still empty + expect(channel.state?.messages.length, equals(0)); + }, + ); + + test( + 'should delete messages from persistence when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.updateMessage(message1); + channel.state?.updateMessage(message2); + channel.state?.updateMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are removed from persistence + verify( + () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => + persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + }, + ); + + test( + 'should not delete from persistence when hardDelete is false', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.updateMessage(message1); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify persistence deletion methods were NOT called + verifyNever(() => persistenceClient.deleteMessageByIds(any())); + verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); + + // Verify message is soft deleted (still in state) + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.first.type, equals(MessageType.deleted)); + }, + ); + }); }); group('ChannelCapabilityCheck', () { diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bd7b9b455..cee14a19b 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/http/token.dart'; @@ -3735,4 +3735,163 @@ void main() { }); }); }); + + group('WS events', () { + late StreamChatClient client; + + setUp(() async { + final ws = FakeWebSocket(); + client = StreamChatClient('test-api-key', ws: ws); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + await client.connectUser(user, token); + await delay(300); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() async { + await client.dispose(); + }); + + group('User messages deleted event', () { + test( + 'should broadcast global user.messages.deleted event to all channels', + () async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = ChannelState( + channel: ChannelModel(id: 'channel-1', type: 'messaging'), + messages: [message1], + ); + final channelState2 = ChannelState( + channel: ChannelModel(id: 'channel-2', type: 'messaging'), + messages: [message2], + ); + + final channel1 = Channel.fromState(client, channelState1); + final channel2 = Channel.fromState(client, channelState2); + + // Register channels in client state + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(1)); + expect(channel2.state?.messages.length, equals(1)); + + // Simulate global user.messages.deleted event being broadcast to channels + // (In production, ClientState._listenUserMessagesDeleted does this) + final event = Event( + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: false, + ); + + client.handleEvent(event); + + // Wait for the events to be processed + await Future.delayed(Duration.zero); + + // Verify messages are soft deleted in all channels + final channel1Message = channel1.state?.messages.first; + expect(channel1Message?.type, equals(MessageType.deleted)); + expect(channel1Message?.state.isDeleted, isTrue); + + final channel2Message = channel2.state?.messages.first; + expect(channel2Message?.type, equals(MessageType.deleted)); + expect(channel2Message?.state.isDeleted, isTrue); + }, + ); + + test( + 'should broadcast global hard delete to all channels', + () async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final otherUser = User(id: 'other-user', name: 'Other User'); + + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + final message3 = Message( + id: 'msg-3', + text: 'Safe message', + user: otherUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = ChannelState( + channel: ChannelModel(id: 'channel-1', type: 'messaging'), + messages: [message1, message3], + ); + final channelState2 = ChannelState( + channel: ChannelModel(id: 'channel-2', type: 'messaging'), + messages: [message2], + ); + + final channel1 = Channel.fromState(client, channelState1); + final channel2 = Channel.fromState(client, channelState2); + + // Register channels in client state + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(2)); + expect(channel2.state?.messages.length, equals(1)); + + // Simulate global user.messages.deleted event being broadcast to channels + // (In production, ClientState._listenUserMessagesDeleted does this) + final event = Event( + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: true, + ); + + client.handleEvent(event); + + // Wait for the events to be processed + await Future.delayed(Duration.zero); + + // Verify banned user's messages are removed from all channels + expect(channel1.state?.messages.length, equals(1)); + expect( + channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), + isFalse, + ); + expect(channel2.state?.messages.length, equals(0)); + + // Verify other user's message is unaffected + final safeMessage = + channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(safeMessage?.user?.id, equals('other-user')); + }, + ); + }); + }); }