From a128ed593e50aac7f28296bd82f8c0edf050c41a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 21 Aug 2025 22:49:44 +0100 Subject: [PATCH 01/11] Handle user deleted messages event --- .../UserChannelBanEventsMiddleware.swift | 9 ++++ .../WebSocketClient/Events/EventPayload.swift | 5 +++ .../WebSocketClient/Events/EventType.swift | 4 ++ .../WebSocketClient/Events/UserEvents.swift | 34 +++++++++++++++ ...UserChannelBanEventsMiddleware_Tests.swift | 42 +++++++++++++++++++ 5 files changed, 94 insertions(+) diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift index 9ffdf9f4ce3..243d7d23f86 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift @@ -30,6 +30,15 @@ struct UserChannelBanEventsMiddleware: EventMiddleware { memberDTO.isShadowBanned = false memberDTO.banExpiresAt = nil + case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO: + let userId = userMessagesDeletedEvent.user.id + guard let userDTO = session.user(id: userId) else { + throw ClientError.UserDoesNotExist(userId: userId) + } + userDTO.messages?.forEach { + $0.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + } + default: break } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 689d6e61952..b5652198560 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -28,6 +28,7 @@ class EventPayload: Decodable { case banExpiredAt = "expiration" case parentId = "parent_id" case hardDelete = "hard_delete" + case softDelete = "soft_delete" case firstUnreadMessageId = "first_unread_message_id" case lastReadAt = "last_read_at" case lastReadMessageId = "last_read_message_id" @@ -60,6 +61,7 @@ class EventPayload: Decodable { let banReason: String? let banExpiredAt: Date? let parentId: MessageId? + let softDelete: Bool? let hardDelete: Bool let shadow: Bool? // Mark as unread properties @@ -99,6 +101,7 @@ class EventPayload: Decodable { banExpiredAt: Date? = nil, parentId: MessageId? = nil, hardDelete: Bool = false, + softDelete: Bool? = nil, shadow: Bool? = nil, firstUnreadMessageId: MessageId? = nil, lastReadAt: Date? = nil, @@ -132,6 +135,7 @@ class EventPayload: Decodable { self.banExpiredAt = banExpiredAt self.parentId = parentId self.hardDelete = hardDelete + self.softDelete = softDelete self.shadow = shadow self.firstUnreadMessageId = firstUnreadMessageId self.lastReadAt = lastReadAt @@ -170,6 +174,7 @@ class EventPayload: Decodable { banExpiredAt = try container.decodeIfPresent(Date.self, forKey: .banExpiredAt) parentId = try container.decodeIfPresent(MessageId.self, forKey: .parentId) hardDelete = try container.decodeIfPresent(Bool.self, forKey: .hardDelete) ?? false + softDelete = try container.decodeIfPresent(Bool.self, forKey: .softDelete) shadow = try container.decodeIfPresent(Bool.self, forKey: .shadow) firstUnreadMessageId = try container.decodeIfPresent(MessageId.self, forKey: .firstUnreadMessageId) lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt) diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index 504ef2e2502..b02d7f4bf37 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -38,6 +38,8 @@ public extension EventType { static let userBanned: Self = "user.banned" /// When a user was unbanned. static let userUnbanned: Self = "user.unbanned" + /// When the messages of a banned user should be deleted. + static let userMessagesDeleted: Self = "user.messages.deleted" // MARK: Channel Events @@ -191,6 +193,8 @@ extension EventType { return try (try? UserBannedEventDTO(from: response)) ?? UserGloballyBannedEventDTO(from: response) case .userUnbanned: return try (try? UserUnbannedEventDTO(from: response)) ?? UserGloballyUnbannedEventDTO(from: response) + case .userMessagesDeleted: + return try UserMessagesDeletedEventDTO(from: response) case .channelCreated: throw ClientError.IgnoredEventType() case .channelUpdated: return try ChannelUpdatedEventDTO(from: response) diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 0da70e117b9..6c6168d9d96 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -271,3 +271,37 @@ class UserUnbannedEventDTO: EventDTO { ) } } + +/// Triggered when the messages of a banned user should be deleted. +public struct UserMessagesDeletedEvent: Event { + /// The unbanned user. + public let user: ChatUser + + /// If the messages should be soft deleted or not. + public let softDelete: Bool + + /// The event timestamp + public let createdAt: Date? +} + +class UserMessagesDeletedEventDTO: EventDTO { + let user: UserPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + user = try response.value(at: \.user) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: DatabaseSession) -> Event? { + guard let userDTO = session.user(id: user.id) else { return nil } + + return try? UserMessagesDeletedEvent( + user: userDTO.asModel(), + softDelete: payload.softDelete ?? false, + createdAt: createdAt + ) + } +} diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift index d3197527a7f..98dd740a9c5 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift @@ -195,4 +195,46 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { XCTAssert(forwardedEvent is UserUnbannedEventDTO) } + + func test_middleware_handlesUserMessagesDeletedEventCorrectly() throws { + // Create event payload + let eventPayload: EventPayload = .init( + eventType: .userMessagesDeleted, + cid: .unique, + user: .dummy(userId: .unique, name: "Luke", imageUrl: nil, extraData: [:]), + createdAt: .unique, + softDelete: true + ) + + // Create event with payload. + let event = try UserMessagesDeletedEventDTO(from: eventPayload) + + // Create required objects in the DB + let userId = eventPayload.user!.id + let messageId1: MessageId = .unique + let messageId2: MessageId = .unique + + try database.createCurrentUser(id: userId) + try database.createChannel(cid: eventPayload.cid!) + try database.createMessage(id: messageId1, authorId: userId, cid: eventPayload.cid!) + try database.createMessage(id: messageId2, authorId: userId, cid: eventPayload.cid!) + + // Verify user and messages exist + let userDTO = try XCTUnwrap(database.viewContext.user(id: userId)) + let message1 = try XCTUnwrap(database.viewContext.message(id: messageId1)) + let message2 = try XCTUnwrap(database.viewContext.message(id: messageId2)) + + // Verify messages are not deleted initially + XCTAssertNil(message1.deletedAt) + XCTAssertNil(message2.deletedAt) + + // Simulate `UserMessagesDeletedEvent` event. + let forwardedEvent = middleware.handle(event: event, session: database.viewContext) + + // Assert the user's messages are marked as deleted + XCTAssertEqual(message1.deletedAt?.bridgeDate, eventPayload.createdAt!) + XCTAssertEqual(message2.deletedAt?.bridgeDate, eventPayload.createdAt!) + + XCTAssert(forwardedEvent is UserMessagesDeletedEventDTO) + } } From 8185398b5c660686368c444a7cba336e090d5846 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 00:08:54 +0100 Subject: [PATCH 02/11] Remove unused code --- .../ChannelController/LivestreamChannelController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 0f6de2b45c3..36b7bd1b39e 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -823,9 +823,6 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel paginationStateHandler.begin(pagination: pagination) } - let endpoint: Endpoint = - .updateChannel(query: channelQuery) - let requestCompletion: (Result) -> Void = { [weak self] result in self?.callback { [weak self] in guard let self = self else { return } From b1c22b25b6282f7a6ca7b67f20ca678943c50a8a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 00:14:59 +0100 Subject: [PATCH 03/11] Handle event in livestream controller as well --- .../LivestreamChannelController.swift | 25 +++- .../WebSocketClient/Events/UserEvents.swift | 2 +- .../LivestreamChannelController_Tests.swift | 132 ++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 36b7bd1b39e..ee3c452969f 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -790,11 +790,18 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel // MARK: - EventsControllerDelegate public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { - guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { - return + if let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid { + handleChannelEvent(event) } - handleChannelEvent(event) + // User deleted messages event is a global event, not tied to a channel. + if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent { + if userMessagesDeletedEvent.softDelete { + let userId = userMessagesDeletedEvent.user.id + let deletedAt = userMessagesDeletedEvent.createdAt + softDeleteMessages(from: userId, deletedAt: deletedAt) + } + } } // MARK: - AppStateObserverDelegate @@ -1082,6 +1089,18 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel messages.removeAll { $0.id == deletedMessage.id } } + private func softDeleteMessages(from userId: UserId, deletedAt: Date) { + let messagesWithDeletedMessages = messages.map { message in + if message.author.id == userId { + return message.changing( + deletedAt: deletedAt + ) + } + return message + } + messages = messagesWithDeletedMessages + } + private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { updateMessage(reactionEvent.message) } diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 6c6168d9d96..4c15f04238e 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -281,7 +281,7 @@ public struct UserMessagesDeletedEvent: Event { public let softDelete: Bool /// The event timestamp - public let createdAt: Date? + public let createdAt: Date } class UserMessagesDeletedEventDTO: EventDTO { diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index 094c2485c17..b74911d03f2 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -2299,6 +2299,138 @@ extension LivestreamChannelController_Tests { XCTAssertEqual(controller.skippedMessagesAmount, 1) XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused } + + func test_didReceiveEvent_userMessagesDeletedEvent_softDelete_marksUserMessagesAsDeleted() { + // Given + let bannedUserId = UserId.unique + let otherUserId = UserId.unique + let eventCreatedAt = Date() + + // Add messages from both users + let bannedUserMessage1 = ChatMessage.mock( + id: "banned1", + cid: controller.cid!, + text: "Message from banned user 1", + author: .mock(id: bannedUserId) + ) + let bannedUserMessage2 = ChatMessage.mock( + id: "banned2", + cid: controller.cid!, + text: "Message from banned user 2", + author: .mock(id: bannedUserId) + ) + let otherUserMessage = ChatMessage.mock( + id: "other", + cid: controller.cid!, + text: "Message from other user", + author: .mock(id: otherUserId) + ) + + // Add messages to controller + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage1, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage2, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: otherUserId), + message: otherUserMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + XCTAssertEqual(controller.messages.count, 3) + XCTAssertNil(controller.messages.first { $0.id == "banned1" }?.deletedAt) + XCTAssertNil(controller.messages.first { $0.id == "banned2" }?.deletedAt) + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + + // When + let userMessagesDeletedEvent = UserMessagesDeletedEvent( + user: .mock(id: bannedUserId), + softDelete: true, + createdAt: eventCreatedAt + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: userMessagesDeletedEvent + ) + + // Then + XCTAssertEqual(controller.messages.count, 3) // Messages still present + + // Banned user messages should be marked as deleted + XCTAssertEqual(controller.messages.first { $0.id == "banned1" }?.deletedAt, eventCreatedAt) + XCTAssertEqual(controller.messages.first { $0.id == "banned2" }?.deletedAt, eventCreatedAt) + + // Other user message should not be affected + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + } + + func test_didReceiveEvent_userMessagesDeletedEvent_softDeleteFalse_doesNotMarkMessagesAsDeleted() { + // Given + let bannedUserId = UserId.unique + let eventCreatedAt = Date() + + // Add message from banned user + let bannedUserMessage = ChatMessage.mock( + id: "banned", + cid: controller.cid!, + text: "Message from banned user", + author: .mock(id: bannedUserId) + ) + + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + XCTAssertEqual(controller.messages.count, 1) + XCTAssertNil(controller.messages.first?.deletedAt) + + // When + let userMessagesDeletedEvent = UserMessagesDeletedEvent( + user: .mock(id: bannedUserId), + softDelete: false, // Hard delete - should not affect in-memory messages + createdAt: eventCreatedAt + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: userMessagesDeletedEvent + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) + XCTAssertNil(controller.messages.first?.deletedAt) // Should not be marked as deleted + } } // MARK: - Message CRUD Tests From 59c831a0bb1f59cc28afaed18a58745337c45629 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 00:24:44 +0100 Subject: [PATCH 04/11] Improve robustness of parting the event in case the user does not exist in the local DB --- .../UserChannelBanEventsMiddleware.swift | 9 ++- .../WebSocketClient/Events/UserEvents.swift | 15 +++-- ...UserChannelBanEventsMiddleware_Tests.swift | 59 +++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift index 243d7d23f86..b5b1bd9a2d5 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift @@ -32,11 +32,10 @@ struct UserChannelBanEventsMiddleware: EventMiddleware { case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO: let userId = userMessagesDeletedEvent.user.id - guard let userDTO = session.user(id: userId) else { - throw ClientError.UserDoesNotExist(userId: userId) - } - userDTO.messages?.forEach { - $0.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + if let userDTO = session.user(id: userId) { + userDTO.messages?.forEach { + $0.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + } } default: diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 4c15f04238e..95e800eb356 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -296,10 +296,17 @@ class UserMessagesDeletedEventDTO: EventDTO { } func toDomainEvent(session: DatabaseSession) -> Event? { - guard let userDTO = session.user(id: user.id) else { return nil } - - return try? UserMessagesDeletedEvent( - user: userDTO.asModel(), + if let userDTO = session.user(id: user.id), + let userModel = try? userDTO.asModel() { + return UserMessagesDeletedEvent( + user: userModel, + softDelete: payload.softDelete ?? false, + createdAt: createdAt + ) + } + + return UserMessagesDeletedEvent( + user: user.asModel(), softDelete: payload.softDelete ?? false, createdAt: createdAt ) diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift index 98dd740a9c5..8eeadac644d 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift @@ -237,4 +237,63 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { XCTAssert(forwardedEvent is UserMessagesDeletedEventDTO) } + + func test_userMessagesDeletedEventDTO_toDomainEvent_whenUserExistsInDB_returnsEventWithDBUser() throws { + // Create event payload + let eventPayload: EventPayload = .init( + eventType: .userMessagesDeleted, + cid: .unique, + user: .dummy(userId: .unique, name: "ExistingUser", imageUrl: nil, extraData: [:]), + createdAt: .unique, + softDelete: true + ) + + // Create event with payload. + let eventDTO = try UserMessagesDeletedEventDTO(from: eventPayload) + + // Create user in DB + let userId = eventPayload.user!.id + try database.createCurrentUser(id: userId) + + // Convert to domain event + let domainEvent = eventDTO.toDomainEvent(session: database.viewContext) + + // Assert event is created and uses user from DB + XCTAssertNotNil(domainEvent) + XCTAssert(domainEvent is UserMessagesDeletedEvent) + if let userMessagesDeletedEvent = domainEvent as? UserMessagesDeletedEvent { + XCTAssertEqual(userMessagesDeletedEvent.user.id, userId) + XCTAssertEqual(userMessagesDeletedEvent.softDelete, true) + XCTAssertEqual(userMessagesDeletedEvent.createdAt, eventPayload.createdAt) + } + } + + func test_userMessagesDeletedEventDTO_toDomainEvent_whenUserDoesNotExistInDB_returnsEventWithPayloadUser() throws { + // Create event payload for user not in DB + let eventPayload: EventPayload = .init( + eventType: .userMessagesDeleted, + cid: .unique, + user: .dummy(userId: .unique, name: "NonExistentUser", imageUrl: nil, extraData: [:]), + createdAt: .unique, + softDelete: false + ) + + // Create event with payload. + let eventDTO = try UserMessagesDeletedEventDTO(from: eventPayload) + + // Do not create user in DB + + // Convert to domain event + let domainEvent = eventDTO.toDomainEvent(session: database.viewContext) + + // Assert event is created using payload user data as fallback + XCTAssertNotNil(domainEvent) + XCTAssert(domainEvent is UserMessagesDeletedEvent) + if let userMessagesDeletedEvent = domainEvent as? UserMessagesDeletedEvent { + XCTAssertEqual(userMessagesDeletedEvent.user.id, eventPayload.user!.id) + XCTAssertEqual(userMessagesDeletedEvent.user.name, "NonExistentUser") + XCTAssertEqual(userMessagesDeletedEvent.softDelete, false) + XCTAssertEqual(userMessagesDeletedEvent.createdAt, eventPayload.createdAt) + } + } } From 71011390bd27dc071328dcbf8c7ca93c5e5794f2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 00:32:30 +0100 Subject: [PATCH 05/11] Fix deleted message not updating for livestream controller --- Sources/StreamChat/Models/ChatMessage.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 80edd933d4f..14711b8c644 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -554,6 +554,7 @@ extension ChatMessage: Hashable { guard lhs.id == rhs.id else { return false } guard lhs.localState == rhs.localState else { return false } guard lhs.updatedAt == rhs.updatedAt else { return false } + guard lhs.deletedAt == rhs.deletedAt else { return false } guard lhs.allAttachments == rhs.allAttachments else { return false } guard lhs.poll == rhs.poll else { return false } guard lhs.author == rhs.author else { return false } From 49493c12419e40c64713e4a0943d8f12d01cdc24 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 16:21:59 +0100 Subject: [PATCH 06/11] Refactor event to use har_delete instead of soft_delete --- .../LivestreamChannelController.swift | 9 ++-- .../WebSocketClient/Events/EventPayload.swift | 5 -- .../WebSocketClient/Events/UserEvents.swift | 8 ++-- .../LivestreamChannelController_Tests.swift | 48 +------------------ ...UserChannelBanEventsMiddleware_Tests.swift | 10 ++-- 5 files changed, 16 insertions(+), 64 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index ee3c452969f..65d2ce7b5d9 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -796,11 +796,12 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel // User deleted messages event is a global event, not tied to a channel. if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent { - if userMessagesDeletedEvent.softDelete { - let userId = userMessagesDeletedEvent.user.id - let deletedAt = userMessagesDeletedEvent.createdAt - softDeleteMessages(from: userId, deletedAt: deletedAt) + if userMessagesDeletedEvent.hardDelete { + return } + let userId = userMessagesDeletedEvent.user.id + let deletedAt = userMessagesDeletedEvent.createdAt + softDeleteMessages(from: userId, deletedAt: deletedAt) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index b5652198560..689d6e61952 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -28,7 +28,6 @@ class EventPayload: Decodable { case banExpiredAt = "expiration" case parentId = "parent_id" case hardDelete = "hard_delete" - case softDelete = "soft_delete" case firstUnreadMessageId = "first_unread_message_id" case lastReadAt = "last_read_at" case lastReadMessageId = "last_read_message_id" @@ -61,7 +60,6 @@ class EventPayload: Decodable { let banReason: String? let banExpiredAt: Date? let parentId: MessageId? - let softDelete: Bool? let hardDelete: Bool let shadow: Bool? // Mark as unread properties @@ -101,7 +99,6 @@ class EventPayload: Decodable { banExpiredAt: Date? = nil, parentId: MessageId? = nil, hardDelete: Bool = false, - softDelete: Bool? = nil, shadow: Bool? = nil, firstUnreadMessageId: MessageId? = nil, lastReadAt: Date? = nil, @@ -135,7 +132,6 @@ class EventPayload: Decodable { self.banExpiredAt = banExpiredAt self.parentId = parentId self.hardDelete = hardDelete - self.softDelete = softDelete self.shadow = shadow self.firstUnreadMessageId = firstUnreadMessageId self.lastReadAt = lastReadAt @@ -174,7 +170,6 @@ class EventPayload: Decodable { banExpiredAt = try container.decodeIfPresent(Date.self, forKey: .banExpiredAt) parentId = try container.decodeIfPresent(MessageId.self, forKey: .parentId) hardDelete = try container.decodeIfPresent(Bool.self, forKey: .hardDelete) ?? false - softDelete = try container.decodeIfPresent(Bool.self, forKey: .softDelete) shadow = try container.decodeIfPresent(Bool.self, forKey: .shadow) firstUnreadMessageId = try container.decodeIfPresent(MessageId.self, forKey: .firstUnreadMessageId) lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt) diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 95e800eb356..8542c41cd9f 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -277,8 +277,8 @@ public struct UserMessagesDeletedEvent: Event { /// The unbanned user. public let user: ChatUser - /// If the messages should be soft deleted or not. - public let softDelete: Bool + /// If the messages should be hard deleted or not. + public let hardDelete: Bool /// The event timestamp public let createdAt: Date @@ -300,14 +300,14 @@ class UserMessagesDeletedEventDTO: EventDTO { let userModel = try? userDTO.asModel() { return UserMessagesDeletedEvent( user: userModel, - softDelete: payload.softDelete ?? false, + hardDelete: payload.hardDelete, createdAt: createdAt ) } return UserMessagesDeletedEvent( user: user.asModel(), - softDelete: payload.softDelete ?? false, + hardDelete: payload.hardDelete, createdAt: createdAt ) } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index b74911d03f2..786269f2ed0 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -2300,7 +2300,7 @@ extension LivestreamChannelController_Tests { XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused } - func test_didReceiveEvent_userMessagesDeletedEvent_softDelete_marksUserMessagesAsDeleted() { + func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteFalse_marksUserMessagesAsSoftDeleted() { // Given let bannedUserId = UserId.unique let otherUserId = UserId.unique @@ -2369,7 +2369,7 @@ extension LivestreamChannelController_Tests { // When let userMessagesDeletedEvent = UserMessagesDeletedEvent( user: .mock(id: bannedUserId), - softDelete: true, + hardDelete: false, createdAt: eventCreatedAt ) controller.eventsController( @@ -2387,50 +2387,6 @@ extension LivestreamChannelController_Tests { // Other user message should not be affected XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) } - - func test_didReceiveEvent_userMessagesDeletedEvent_softDeleteFalse_doesNotMarkMessagesAsDeleted() { - // Given - let bannedUserId = UserId.unique - let eventCreatedAt = Date() - - // Add message from banned user - let bannedUserMessage = ChatMessage.mock( - id: "banned", - cid: controller.cid!, - text: "Message from banned user", - author: .mock(id: bannedUserId) - ) - - controller.eventsController( - EventsController(notificationCenter: client.eventNotificationCenter), - didReceiveEvent: MessageNewEvent( - user: .mock(id: bannedUserId), - message: bannedUserMessage, - channel: .mock(cid: controller.cid!), - createdAt: .unique, - watcherCount: nil, - unreadCount: nil - ) - ) - - XCTAssertEqual(controller.messages.count, 1) - XCTAssertNil(controller.messages.first?.deletedAt) - - // When - let userMessagesDeletedEvent = UserMessagesDeletedEvent( - user: .mock(id: bannedUserId), - softDelete: false, // Hard delete - should not affect in-memory messages - createdAt: eventCreatedAt - ) - controller.eventsController( - EventsController(notificationCenter: client.eventNotificationCenter), - didReceiveEvent: userMessagesDeletedEvent - ) - - // Then - XCTAssertEqual(controller.messages.count, 1) - XCTAssertNil(controller.messages.first?.deletedAt) // Should not be marked as deleted - } } // MARK: - Message CRUD Tests diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift index 8eeadac644d..3530fc3e953 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift @@ -203,7 +203,7 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { cid: .unique, user: .dummy(userId: .unique, name: "Luke", imageUrl: nil, extraData: [:]), createdAt: .unique, - softDelete: true + hardDelete: false ) // Create event with payload. @@ -245,7 +245,7 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { cid: .unique, user: .dummy(userId: .unique, name: "ExistingUser", imageUrl: nil, extraData: [:]), createdAt: .unique, - softDelete: true + hardDelete: false ) // Create event with payload. @@ -263,7 +263,7 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { XCTAssert(domainEvent is UserMessagesDeletedEvent) if let userMessagesDeletedEvent = domainEvent as? UserMessagesDeletedEvent { XCTAssertEqual(userMessagesDeletedEvent.user.id, userId) - XCTAssertEqual(userMessagesDeletedEvent.softDelete, true) + XCTAssertEqual(userMessagesDeletedEvent.hardDelete, false) XCTAssertEqual(userMessagesDeletedEvent.createdAt, eventPayload.createdAt) } } @@ -275,7 +275,7 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { cid: .unique, user: .dummy(userId: .unique, name: "NonExistentUser", imageUrl: nil, extraData: [:]), createdAt: .unique, - softDelete: false + hardDelete: true ) // Create event with payload. @@ -292,7 +292,7 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { if let userMessagesDeletedEvent = domainEvent as? UserMessagesDeletedEvent { XCTAssertEqual(userMessagesDeletedEvent.user.id, eventPayload.user!.id) XCTAssertEqual(userMessagesDeletedEvent.user.name, "NonExistentUser") - XCTAssertEqual(userMessagesDeletedEvent.softDelete, false) + XCTAssertEqual(userMessagesDeletedEvent.hardDelete, true) XCTAssertEqual(userMessagesDeletedEvent.createdAt, eventPayload.createdAt) } } From 6d4ca1a8b684fd2b13b0310a310caf68416bb873 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 16:31:38 +0100 Subject: [PATCH 07/11] Handle hard deletion --- .../LivestreamChannelController.swift | 15 +++- .../UserChannelBanEventsMiddleware.swift | 8 +- .../LivestreamChannelController_Tests.swift | 89 +++++++++++++++++++ ...UserChannelBanEventsMiddleware_Tests.swift | 52 +++++++++++ 4 files changed, 158 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 65d2ce7b5d9..3369ef01871 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -796,12 +796,13 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel // User deleted messages event is a global event, not tied to a channel. if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent { + let userId = userMessagesDeletedEvent.user.id if userMessagesDeletedEvent.hardDelete { - return + hardDeleteMessages(from: userId) + } else { + let deletedAt = userMessagesDeletedEvent.createdAt + softDeleteMessages(from: userId, deletedAt: deletedAt) } - let userId = userMessagesDeletedEvent.user.id - let deletedAt = userMessagesDeletedEvent.createdAt - softDeleteMessages(from: userId, deletedAt: deletedAt) } } @@ -1102,6 +1103,12 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel messages = messagesWithDeletedMessages } + private func hardDeleteMessages(from userId: UserId) { + messages.removeAll { message in + message.author.id == userId + } + } + private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { updateMessage(reactionEvent.message) } diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift index b5b1bd9a2d5..9f023e2636f 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift @@ -33,8 +33,12 @@ struct UserChannelBanEventsMiddleware: EventMiddleware { case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO: let userId = userMessagesDeletedEvent.user.id if let userDTO = session.user(id: userId) { - userDTO.messages?.forEach { - $0.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + userDTO.messages?.forEach { message in + if userMessagesDeletedEvent.payload.hardDelete { + message.isHardDeleted = true + } else { + message.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + } } } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index 786269f2ed0..7568c73229c 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -2387,6 +2387,95 @@ extension LivestreamChannelController_Tests { // Other user message should not be affected XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) } + + func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteTrue_removesUserMessages() { + // Given + let bannedUserId = UserId.unique + let otherUserId = UserId.unique + let eventCreatedAt = Date() + + // Add messages from both users + let bannedUserMessage1 = ChatMessage.mock( + id: "banned1", + cid: controller.cid!, + text: "Message from banned user 1", + author: .mock(id: bannedUserId) + ) + let bannedUserMessage2 = ChatMessage.mock( + id: "banned2", + cid: controller.cid!, + text: "Message from banned user 2", + author: .mock(id: bannedUserId) + ) + let otherUserMessage = ChatMessage.mock( + id: "other", + cid: controller.cid!, + text: "Message from other user", + author: .mock(id: otherUserId) + ) + + // Add messages to controller + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage1, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage2, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: otherUserId), + message: otherUserMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + XCTAssertEqual(controller.messages.count, 3) + XCTAssertNotNil(controller.messages.first { $0.id == "banned1" }) + XCTAssertNotNil(controller.messages.first { $0.id == "banned2" }) + XCTAssertNotNil(controller.messages.first { $0.id == "other" }) + + // When + let userMessagesDeletedEvent = UserMessagesDeletedEvent( + user: .mock(id: bannedUserId), + hardDelete: true, + createdAt: eventCreatedAt + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: userMessagesDeletedEvent + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) // Only other user's message remains + + // Banned user messages should be completely removed + XCTAssertNil(controller.messages.first { $0.id == "banned1" }) + XCTAssertNil(controller.messages.first { $0.id == "banned2" }) + + // Other user message should remain unaffected + XCTAssertNotNil(controller.messages.first { $0.id == "other" }) + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + } } // MARK: - Message CRUD Tests diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift index 3530fc3e953..bb85da75f28 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware_Tests.swift @@ -227,6 +227,8 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { // Verify messages are not deleted initially XCTAssertNil(message1.deletedAt) XCTAssertNil(message2.deletedAt) + XCTAssertFalse(message1.isHardDeleted) + XCTAssertFalse(message2.isHardDeleted) // Simulate `UserMessagesDeletedEvent` event. let forwardedEvent = middleware.handle(event: event, session: database.viewContext) @@ -234,6 +236,56 @@ final class UserChannelBanEventsMiddleware_Tests: XCTestCase { // Assert the user's messages are marked as deleted XCTAssertEqual(message1.deletedAt?.bridgeDate, eventPayload.createdAt!) XCTAssertEqual(message2.deletedAt?.bridgeDate, eventPayload.createdAt!) + // Soft delete should not set isHardDeleted flag + XCTAssertFalse(message1.isHardDeleted) + XCTAssertFalse(message2.isHardDeleted) + + XCTAssert(forwardedEvent is UserMessagesDeletedEventDTO) + } + + func test_middleware_handlesUserMessagesDeletedEvent_hardDelete_marksMessagesAsHardDeleted() throws { + // Create event payload with hard delete + let eventPayload: EventPayload = .init( + eventType: .userMessagesDeleted, + cid: .unique, + user: .dummy(userId: .unique, name: "Luke", imageUrl: nil, extraData: [:]), + createdAt: .unique, + hardDelete: true + ) + + // Create event with payload. + let event = try UserMessagesDeletedEventDTO(from: eventPayload) + + // Create required objects in the DB + let userId = eventPayload.user!.id + let messageId1: MessageId = .unique + let messageId2: MessageId = .unique + + try database.createCurrentUser(id: userId) + try database.createChannel(cid: eventPayload.cid!) + try database.createMessage(id: messageId1, authorId: userId, cid: eventPayload.cid!) + try database.createMessage(id: messageId2, authorId: userId, cid: eventPayload.cid!) + + // Verify user and messages exist + let userDTO = try XCTUnwrap(database.viewContext.user(id: userId)) + let message1 = try XCTUnwrap(database.viewContext.message(id: messageId1)) + let message2 = try XCTUnwrap(database.viewContext.message(id: messageId2)) + + // Verify messages are not hard deleted initially + XCTAssertFalse(message1.isHardDeleted) + XCTAssertFalse(message2.isHardDeleted) + XCTAssertNil(message1.deletedAt) + XCTAssertNil(message2.deletedAt) + + // Simulate `UserMessagesDeletedEvent` event with hard delete. + let forwardedEvent = middleware.handle(event: event, session: database.viewContext) + + // Assert the user's messages are marked as hard deleted + XCTAssertTrue(message1.isHardDeleted) + XCTAssertTrue(message2.isHardDeleted) + // deletedAt should not be set for hard deletes + XCTAssertNil(message1.deletedAt) + XCTAssertNil(message2.deletedAt) XCTAssert(forwardedEvent is UserMessagesDeletedEventDTO) } From b59c7bcae61fdd45baea5b8491a0c01fea51ff0c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 16:41:10 +0100 Subject: [PATCH 08/11] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34686ca2a7f..20da0702e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +## StreamChat +### ✅ Added +- Add support for `user.deleted.messages` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792) ## StreamChat ### 🐞 Fixed From 4508937c2ada18db231997f9c1317eb9aa6ab6aa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 16:43:47 +0100 Subject: [PATCH 09/11] Update CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bca8aa433f..2e1af8d95f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming + ## StreamChat ### ✅ Added - Add support for `user.deleted.messages` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792) -### 🔄 Changed - # [4.86.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.86.0) _August 21, 2025_ From e26515318f561e6e657322784c739e7e6f4141c6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 16:43:58 +0100 Subject: [PATCH 10/11] Update Sources/StreamChat/WebSocketClient/Events/UserEvents.swift --- Sources/StreamChat/WebSocketClient/Events/UserEvents.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 8542c41cd9f..ee17af1049a 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -274,7 +274,7 @@ class UserUnbannedEventDTO: EventDTO { /// Triggered when the messages of a banned user should be deleted. public struct UserMessagesDeletedEvent: Event { - /// The unbanned user. + /// The banned user. public let user: ChatUser /// If the messages should be hard deleted or not. From 706c4ca40e19f474a42c3a93eae907f8a4f275ee Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 22 Aug 2025 17:00:28 +0100 Subject: [PATCH 11/11] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1af8d95f6..9a60e844c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added -- Add support for `user.deleted.messages` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792) +- Add support for `user.messages.deleted` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792) # [4.86.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.86.0) _August 21, 2025_