Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### πŸ”„ Changed
## StreamChat
### βœ… Added
- 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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,20 @@ 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 {
let userId = userMessagesDeletedEvent.user.id
if userMessagesDeletedEvent.hardDelete {
hardDeleteMessages(from: userId)
} else {
let deletedAt = userMessagesDeletedEvent.createdAt
softDeleteMessages(from: userId, deletedAt: deletedAt)
}
}
}

// MARK: - AppStateObserverDelegate
Expand Down Expand Up @@ -823,9 +832,6 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
paginationStateHandler.begin(pagination: pagination)
}

let endpoint: Endpoint<ChannelPayload> =
.updateChannel(query: channelQuery)

let requestCompletion: (Result<ChannelPayload, Error>) -> Void = { [weak self] result in
self?.callback { [weak self] in
guard let self = self else { return }
Expand Down Expand Up @@ -1085,6 +1091,24 @@ 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 hardDeleteMessages(from userId: UserId) {
messages.removeAll { message in
message.author.id == userId
}
}

private func handleNewReaction(_ reactionEvent: ReactionNewEvent) {
updateMessage(reactionEvent.message)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/StreamChat/Models/ChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ struct UserChannelBanEventsMiddleware: EventMiddleware {
memberDTO.isShadowBanned = false
memberDTO.banExpiresAt = nil

case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO:
let userId = userMessagesDeletedEvent.user.id
if let userDTO = session.user(id: userId) {
userDTO.messages?.forEach { message in
if userMessagesDeletedEvent.payload.hardDelete {
message.isHardDeleted = true
} else {
message.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate
}
}
}

default:
break
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/WebSocketClient/Events/EventType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions Sources/StreamChat/WebSocketClient/Events/UserEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,44 @@ class UserUnbannedEventDTO: EventDTO {
)
}
}

/// Triggered when the messages of a banned user should be deleted.
public struct UserMessagesDeletedEvent: Event {
/// The banned user.
public let user: ChatUser

/// If the messages should be hard deleted or not.
public let hardDelete: 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? {
if let userDTO = session.user(id: user.id),
let userModel = try? userDTO.asModel() {
return UserMessagesDeletedEvent(
user: userModel,
hardDelete: payload.hardDelete,
createdAt: createdAt
)
}

return UserMessagesDeletedEvent(
user: user.asModel(),
hardDelete: payload.hardDelete,
createdAt: createdAt
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,183 @@ extension LivestreamChannelController_Tests {
XCTAssertEqual(controller.skippedMessagesAmount, 1)
XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused
}

func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteFalse_marksUserMessagesAsSoftDeleted() {
// 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),
hardDelete: false,
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_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
Expand Down
Loading
Loading