From f6e0cbf255d3fd04189a9247da91057b2265dbac Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 16:47:51 +0200 Subject: [PATCH 01/11] feat(llc): handle user.messages.deleted event Adds support for the `user.messages.deleted` WebSocket event. This event is now handled at the client level and propagated to all active channels. Each channel will then proceed to either soft or hard delete all messages from the specified user, depending on the event payload. Refactors message update and removal logic to support bulk operations, improving performance and maintainability. --- .../stream_chat/lib/src/client/channel.dart | 413 ++++++++++++------ .../stream_chat/lib/src/client/client.dart | 20 + packages/stream_chat/lib/src/event_type.dart | 3 + 3 files changed, 294 insertions(+), 142 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 17f1641df..2ad0714a2 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}) { @@ -3585,6 +3451,262 @@ class ChannelClientState { ); } + void _deleteMessagesFromUser({ + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) { + final userMessages = [ + ...messages.where((it) => it.user?.id == userId).map((it) { + return it.copyWith( + type: MessageType.deleted, + deletedAt: deletedAt ?? DateTime.now(), + state: MessageState.deleted(hard: hardDelete), + ); + }), + ]; + + return _deleteMessages(userMessages, 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; + + _updatedThreadMessages(messages); + _updateChannelMessages(messages); + } + + void _updatedThreadMessages(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 = _updateMessagesIntoOriginal( + original: threadMessages, + toUpdate: 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 = _updateMessagesIntoOriginal( + original: channelMessages, + toUpdate: affectedMessages, + ).sorted(_sortByCreatedAt); + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _updateMessagesIntoOriginal( + original: pinnedMessages, + toUpdate: affectedMessages, + ).where(_pinIsValid).sorted(_sortByCreatedAt); + + // Calculate the new last message at time. + var lastMessageAt = _channelState.channel?.lastMessageAt; + final lastMessage = updatedChannelMessages.lastOrNull; + if (lastMessage != null && _shouldUpdateChannelLastMessageAt(lastMessage)) { + lastMessageAt = [lastMessageAt, lastMessage.createdAt].nonNulls.max; + } + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages, + pinnedMessages: updatedPinnedMessages, + channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), + ); + } + + List _updateMessagesIntoOriginal({ + required List original, + required List toUpdate, + }) { + if (toUpdate.isEmpty) return original; + + final mergedMessages = original.merge( + toUpdate, + 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 toUpdate) 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 = _removeMessagesFromOriginal( + original: 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 = _removeMessagesFromOriginal( + original: channelMessages, + toRemove: affectedMessages, + ); + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _removeMessagesFromOriginal( + original: pinnedMessages, + toRemove: affectedMessages, + ); + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages, + pinnedMessages: updatedPinnedMessages, + ); + } + + List _removeMessagesFromOriginal({ + required List original, + required List toRemove, + }) { + if (toRemove.isEmpty) return original; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedMessages = [ + ...original.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 +3723,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..bf4ded3e8 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'; } From b9a880998036a5b3f1fc160547786919cd0a2d60 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 16:48:48 +0200 Subject: [PATCH 02/11] chore: update CHANGELOG.md --- packages/stream_chat/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 0cafdbf6a5e397662f7f36dc5e1035dd9ec1f198 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:02:36 +0200 Subject: [PATCH 03/11] refactor: simplify thread update logic The `clearThread` method is simplified to call `updateThreadInfo`. The logic inside `updateThreadInfo` is refactored for clarity, replacing the `merge` and `sorted` chain with a more explicit update process. The method `_updatedThreadMessages` is renamed to `_updateThreadMessages`. --- .../stream_chat/lib/src/client/channel.dart | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 2ad0714a2..e599b5425 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3295,30 +3295,22 @@ class ChannelClientState { } /// Clears all the replies in the thread identified by [parentId]. - void clearThread(String parentId) { - final updatedThreads = { - ...threads, - parentId: [], - }; - - _threads = updatedThreads; - } + void clearThread(String parentId) => updateThreadInfo(parentId, []); /// 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}; + + final threadMessages = [...?updatedThreads[parentId]]; + final updatedThreadMessages = _updateMessagesIntoOriginal( + original: threadMessages, + toUpdate: messages, + ).sorted(_sortByCreatedAt); - _threads = newThreads; + // Update the thread with the modified message list. + updatedThreads[parentId] = updatedThreadMessages; + + _threads = updatedThreads; } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3482,11 +3474,11 @@ class ChannelClientState { void _updateMessages(List messages) { if (messages.isEmpty) return; - _updatedThreadMessages(messages); + _updateThreadMessages(messages); _updateChannelMessages(messages); } - void _updatedThreadMessages(List messages) { + void _updateThreadMessages(List messages) { if (messages.isEmpty) return; final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; From ee9b7b76789dbc185ef8912bd5bc272c107c3be2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:09:28 +0200 Subject: [PATCH 04/11] refactor: rename `_updateMessagesIntoOriginal` to `_mergeMessagesIntoExisting` --- .../stream_chat/lib/src/client/channel.dart | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e599b5425..1658f4c87 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3202,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 = [ @@ -3302,9 +3299,9 @@ class ChannelClientState { final updatedThreads = {...threads}; final threadMessages = [...?updatedThreads[parentId]]; - final updatedThreadMessages = _updateMessagesIntoOriginal( - original: threadMessages, - toUpdate: messages, + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, ).sorted(_sortByCreatedAt); // Update the thread with the modified message list. @@ -3488,9 +3485,9 @@ class ChannelClientState { final updatedThreads = {...threads}; for (final thread in affectedThreads) { final threadMessages = [...?updatedThreads[thread]]; - final updatedThreadMessages = _updateMessagesIntoOriginal( - original: threadMessages, - toUpdate: messages, + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, ).sorted(_sortByCreatedAt); // Update the thread with the modified message list. @@ -3519,15 +3516,15 @@ class ChannelClientState { if (affectedMessages.isEmpty) return; final channelMessages = [...this.messages]; - final updatedChannelMessages = _updateMessagesIntoOriginal( - original: channelMessages, - toUpdate: affectedMessages, + final updatedChannelMessages = _mergeMessagesIntoExisting( + existing: channelMessages, + toMerge: affectedMessages, ).sorted(_sortByCreatedAt); final pinnedMessages = [...this.pinnedMessages]; - final updatedPinnedMessages = _updateMessagesIntoOriginal( - original: pinnedMessages, - toUpdate: affectedMessages, + final updatedPinnedMessages = _mergeMessagesIntoExisting( + existing: pinnedMessages, + toMerge: affectedMessages, ).where(_pinIsValid).sorted(_sortByCreatedAt); // Calculate the new last message at time. @@ -3544,14 +3541,14 @@ class ChannelClientState { ); } - List _updateMessagesIntoOriginal({ - required List original, - required List toUpdate, + List _mergeMessagesIntoExisting({ + required List existing, + required List toMerge, }) { - if (toUpdate.isEmpty) return original; + if (toMerge.isEmpty) return existing; - final mergedMessages = original.merge( - toUpdate, + final mergedMessages = existing.merge( + toMerge, key: (message) => message.id, update: (original, updated) { var merged = updated.syncWith(original); @@ -3565,7 +3562,7 @@ class ChannelClientState { }, ); - final toUpdateMap = {for (final m in toUpdate) m.id: m}; + 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. From e340c1ceeaffa51789e5c158f42d0dc043936a90 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:10:15 +0200 Subject: [PATCH 05/11] refactor: rename `_removeMessagesFromOriginal` to `_removeMessagesFromExisting` --- .../stream_chat/lib/src/client/channel.dart | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 1658f4c87..73c4393b2 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3605,8 +3605,8 @@ class ChannelClientState { // Remove the deleted message from the thread messages and reference from // other messages quoting it. - final updatedThreadMessages = _removeMessagesFromOriginal( - original: threadMessages, + final updatedThreadMessages = _removeMessagesFromExisting( + existing: threadMessages, toRemove: messages, ); @@ -3642,14 +3642,14 @@ class ChannelClientState { if (affectedMessages.isEmpty) return; final channelMessages = [...this.messages]; - final updatedChannelMessages = _removeMessagesFromOriginal( - original: channelMessages, + final updatedChannelMessages = _removeMessagesFromExisting( + existing: channelMessages, toRemove: affectedMessages, ); final pinnedMessages = [...this.pinnedMessages]; - final updatedPinnedMessages = _removeMessagesFromOriginal( - original: pinnedMessages, + final updatedPinnedMessages = _removeMessagesFromExisting( + existing: pinnedMessages, toRemove: affectedMessages, ); @@ -3659,15 +3659,15 @@ class ChannelClientState { ); } - List _removeMessagesFromOriginal({ - required List original, + List _removeMessagesFromExisting({ + required List existing, required List toRemove, }) { - if (toRemove.isEmpty) return original; + if (toRemove.isEmpty) return existing; final toRemoveIds = toRemove.map((m) => m.id).toSet(); final updatedMessages = [ - ...original.where((it) => !toRemoveIds.contains(it.id)).map((it) { + ...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; From db25ae20d73f2433e3f01f9e7c2e58d7ea8527b7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:26:38 +0200 Subject: [PATCH 06/11] fix: delete messages from threads when user is banned When a user is banned, their messages in threads are now also marked as deleted, in addition to their messages directly in the channel. --- .../stream_chat/lib/src/client/channel.dart | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 73c4393b2..3b0bf888d 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3445,17 +3445,18 @@ class ChannelClientState { bool hardDelete = false, DateTime? deletedAt, }) { - final userMessages = [ - ...messages.where((it) => it.user?.id == userId).map((it) { - return it.copyWith( - type: MessageType.deleted, - deletedAt: deletedAt ?? DateTime.now(), - state: MessageState.deleted(hard: hardDelete), - ); - }), - ]; + 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), + ); + } - return _deleteMessages(userMessages, hardDelete: hardDelete); + final messagesToDelete = userMessages.values.toList(); + return _deleteMessages(messagesToDelete, hardDelete: hardDelete); } void _deleteMessages( From 8b62e8e86d1ecc18ba6497f7a4fc5b73caa0a029 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:38:41 +0200 Subject: [PATCH 07/11] fix: concurrent modification and last message update The `channels.keys` iterable is now cloned before iteration to prevent concurrent modification exceptions. Additionally, the logic for updating a channel's `lastMessageAt` timestamp has been corrected to consider all messages, not just the very last one. --- packages/stream_chat/lib/src/client/channel.dart | 7 +++++-- packages/stream_chat/lib/src/client/client.dart | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 3b0bf888d..c3b46b483 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3530,8 +3530,11 @@ class ChannelClientState { // Calculate the new last message at time. var lastMessageAt = _channelState.channel?.lastMessageAt; - final lastMessage = updatedChannelMessages.lastOrNull; - if (lastMessage != null && _shouldUpdateChannelLastMessageAt(lastMessage)) { + final lastMessage = updatedChannelMessages.lastWhereOrNull( + _shouldUpdateChannelLastMessageAt, + ); + + if (lastMessage != null) { lastMessageAt = [lastMessageAt, lastMessage.createdAt].nonNulls.max; } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index bf4ded3e8..205b8d508 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2291,7 +2291,7 @@ class ClientState { // Iterate through all the available channels and send the event // to be handled by the respective channel instances. - for (final cid in channels.keys) { + for (final cid in [...channels.keys]) { final channelEvent = event.copyWith(cid: cid); _client.handleEvent(channelEvent); } From 9ab54399ef2e8e7da8904de1919f6d50c2d899c9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:51:03 +0200 Subject: [PATCH 08/11] chore: revert `clearThread` --- packages/stream_chat/lib/src/client/channel.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index c3b46b483..9c22fabd3 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3292,7 +3292,14 @@ class ChannelClientState { } /// Clears all the replies in the thread identified by [parentId]. - void clearThread(String parentId) => updateThreadInfo(parentId, []); + void clearThread(String parentId) { + final updatedThreads = { + ...threads, + parentId: [], + }; + + _threads = updatedThreads; + } /// Update threads with updated information about messages. void updateThreadInfo(String parentId, List messages) { From c53a45cf2790629529e679885f78fc243df7f65f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 17:54:20 +0200 Subject: [PATCH 09/11] fix: improve `lastMessageAt` update logic --- packages/stream_chat/lib/src/client/channel.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 9c22fabd3..72560c96b 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3537,12 +3537,10 @@ class ChannelClientState { // Calculate the new last message at time. var lastMessageAt = _channelState.channel?.lastMessageAt; - final lastMessage = updatedChannelMessages.lastWhereOrNull( - _shouldUpdateChannelLastMessageAt, - ); - - if (lastMessage != null) { - lastMessageAt = [lastMessageAt, lastMessage.createdAt].nonNulls.max; + for (final message in affectedMessages) { + if (_shouldUpdateChannelLastMessageAt(message)) { + lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; + } } _channelState = _channelState.copyWith( From 52e37be6bf35c40b86b80c5e7c3937b62945c210 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 18:24:24 +0200 Subject: [PATCH 10/11] test: add tests for user.messages.deleted event --- .../test/src/client/channel_test.dart | 274 ++++++++++++++++++ .../test/src/client/client_test.dart | 249 +++++++++++++++- 2 files changed, 522 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index de680785a..f7d5e06dc 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -5195,6 +5195,280 @@ void main() { ); }); }); + + group('User messages deleted event', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + 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)); + }, + ); + }); }); 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..fcf7920d2 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'; @@ -3734,5 +3734,252 @@ void main() { verify(() => api.general.sync(cids, lastSyncAt)).called(1); }); }); + + group('User messages deleted event', () { + const apiKey = 'test-api-key'; + late StreamChatClient client; + + setUp(() { + final ws = FakeWebSocket(); + final api = FakeChatApi(); + client = StreamChatClient(apiKey, ws: ws, chatApi: api); + client.state.currentUser = OwnUser(id: 'test-user'); + }); + + tearDown(() { + client.dispose(); + }); + + 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.channels['messaging:channel-1'] = channel1; + client.state.channels['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 event1 = Event( + cid: channel1.cid, + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: false, + ); + + client.handleEvent(event1); + + final event2 = Event( + cid: channel2.cid, + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: false, + ); + + client.handleEvent(event2); + + // 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); + + // Cleanup + channel1.dispose(); + channel2.dispose(); + }, + ); + + 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.channels['messaging:channel-1'] = channel1; + client.state.channels['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 event1 = Event( + cid: channel1.cid, + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: true, + ); + + client.handleEvent(event1); + + final event2 = Event( + cid: channel2.cid, + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: true, + ); + + client.handleEvent(event2); + + // 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')); + + // Cleanup + channel1.dispose(); + channel2.dispose(); + }, + ); + + test( + 'should not handle channel-specific user.messages.deleted events', + () async { + // Add messages + 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 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.channels['messaging:channel-1'] = channel1; + client.state.channels['messaging:channel-2'] = channel2; + + // Verify initial state + expect(channel1.state?.messages.length, equals(1)); + expect(channel2.state?.messages.length, equals(1)); + + // Create channel-specific event (has cid) + final channelSpecificEvent = Event( + cid: channel1.cid, + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: true, + ); + + // Dispatch event - this should be handled by channel, not client state + client.handleEvent(channelSpecificEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify only channel1 is affected (by channel's own handler) + expect( + channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), + isFalse, + ); + + // Verify channel2 is unaffected (client state didn't broadcast it) + expect(channel2.state?.messages.length, equals(1)); + expect( + channel2.state?.messages.any((m) => m.user?.id == 'banned-user'), + isTrue, + ); + + // Cleanup + channel1.dispose(); + channel2.dispose(); + }, + ); + }); }); } From 695db6cbd1b771ab03646742ca5d827510f43c70 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 13 Oct 2025 18:51:19 +0200 Subject: [PATCH 11/11] test: fix `user.messages.deleted` event tests Refactors the tests for the `user.messages.deleted` WebSocket event to improve reliability and correctness. - Connects the client and waits for a connected status in `setUp`, ensuring a realistic test environment. - Simplifies event dispatching by sending a single global event instead of one per channel. - Removes an unnecessary test for channel-specific `user.messages.deleted` events, as these are handled by the channel itself. - Adds tests to verify that messages are correctly deleted from the persistence client when `hardDelete` is true. --- .../test/src/client/channel_test.dart | 110 +++++++++++++ .../test/src/client/client_test.dart | 150 ++++-------------- 2 files changed, 141 insertions(+), 119 deletions(-) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index f7d5e06dc..895c74bef 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -5200,8 +5200,18 @@ void main() { 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); }); @@ -5468,6 +5478,106 @@ void main() { 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)); + }, + ); }); }); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index fcf7920d2..cee14a19b 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3734,22 +3734,28 @@ void main() { verify(() => api.general.sync(cids, lastSyncAt)).called(1); }); }); + }); - group('User messages deleted event', () { - const apiKey = 'test-api-key'; - late StreamChatClient client; - - setUp(() { - final ws = FakeWebSocket(); - final api = FakeChatApi(); - client = StreamChatClient(apiKey, ws: ws, chatApi: api); - client.state.currentUser = OwnUser(id: 'test-user'); - }); + group('WS events', () { + late StreamChatClient client; - tearDown(() { - client.dispose(); - }); + 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 { @@ -3780,8 +3786,10 @@ void main() { final channel2 = Channel.fromState(client, channelState2); // Register channels in client state - client.state.channels['messaging:channel-1'] = channel1; - client.state.channels['messaging:channel-2'] = channel2; + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); // Verify initial state expect(channel1.state?.messages.length, equals(1)); @@ -3789,23 +3797,13 @@ void main() { // Simulate global user.messages.deleted event being broadcast to channels // (In production, ClientState._listenUserMessagesDeleted does this) - final event1 = Event( - cid: channel1.cid, - type: EventType.userMessagesDeleted, - user: bannedUser, - hardDelete: false, - ); - - client.handleEvent(event1); - - final event2 = Event( - cid: channel2.cid, + final event = Event( type: EventType.userMessagesDeleted, user: bannedUser, hardDelete: false, ); - client.handleEvent(event2); + client.handleEvent(event); // Wait for the events to be processed await Future.delayed(Duration.zero); @@ -3818,10 +3816,6 @@ void main() { final channel2Message = channel2.state?.messages.first; expect(channel2Message?.type, equals(MessageType.deleted)); expect(channel2Message?.state.isDeleted, isTrue); - - // Cleanup - channel1.dispose(); - channel2.dispose(); }, ); @@ -3862,8 +3856,10 @@ void main() { final channel2 = Channel.fromState(client, channelState2); // Register channels in client state - client.state.channels['messaging:channel-1'] = channel1; - client.state.channels['messaging:channel-2'] = channel2; + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); // Verify initial state expect(channel1.state?.messages.length, equals(2)); @@ -3871,23 +3867,13 @@ void main() { // Simulate global user.messages.deleted event being broadcast to channels // (In production, ClientState._listenUserMessagesDeleted does this) - final event1 = Event( - cid: channel1.cid, - type: EventType.userMessagesDeleted, - user: bannedUser, - hardDelete: true, - ); - - client.handleEvent(event1); - - final event2 = Event( - cid: channel2.cid, + final event = Event( type: EventType.userMessagesDeleted, user: bannedUser, hardDelete: true, ); - client.handleEvent(event2); + client.handleEvent(event); // Wait for the events to be processed await Future.delayed(Duration.zero); @@ -3904,80 +3890,6 @@ void main() { final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); expect(safeMessage?.user?.id, equals('other-user')); - - // Cleanup - channel1.dispose(); - channel2.dispose(); - }, - ); - - test( - 'should not handle channel-specific user.messages.deleted events', - () async { - // Add messages - 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 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.channels['messaging:channel-1'] = channel1; - client.state.channels['messaging:channel-2'] = channel2; - - // Verify initial state - expect(channel1.state?.messages.length, equals(1)); - expect(channel2.state?.messages.length, equals(1)); - - // Create channel-specific event (has cid) - final channelSpecificEvent = Event( - cid: channel1.cid, - type: EventType.userMessagesDeleted, - user: bannedUser, - hardDelete: true, - ); - - // Dispatch event - this should be handled by channel, not client state - client.handleEvent(channelSpecificEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify only channel1 is affected (by channel's own handler) - expect( - channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), - isFalse, - ); - - // Verify channel2 is unaffected (client state didn't broadcast it) - expect(channel2.state?.messages.length, equals(1)); - expect( - channel2.state?.messages.any((m) => m.user?.id == 'banned-user'), - isTrue, - ); - - // Cleanup - channel1.dispose(); - channel2.dispose(); }, ); });