Skip to content

Commit 01a3a9d

Browse files
authored
Add support for user deleted messages event (#3792)
1 parent a91b971 commit 01a3a9d

File tree

8 files changed

+421
-7
lines changed

8 files changed

+421
-7
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6-
### 🔄 Changed
6+
## StreamChat
7+
### ✅ Added
8+
- Add support for `user.messages.deleted` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792)
79

810
# [4.86.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.86.0)
911
_August 21, 2025_

Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,20 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
790790
// MARK: - EventsControllerDelegate
791791

792792
public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) {
793-
guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else {
794-
return
793+
if let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid {
794+
handleChannelEvent(event)
795795
}
796796

797-
handleChannelEvent(event)
797+
// User deleted messages event is a global event, not tied to a channel.
798+
if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent {
799+
let userId = userMessagesDeletedEvent.user.id
800+
if userMessagesDeletedEvent.hardDelete {
801+
hardDeleteMessages(from: userId)
802+
} else {
803+
let deletedAt = userMessagesDeletedEvent.createdAt
804+
softDeleteMessages(from: userId, deletedAt: deletedAt)
805+
}
806+
}
798807
}
799808

800809
// MARK: - AppStateObserverDelegate
@@ -823,9 +832,6 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
823832
paginationStateHandler.begin(pagination: pagination)
824833
}
825834

826-
let endpoint: Endpoint<ChannelPayload> =
827-
.updateChannel(query: channelQuery)
828-
829835
let requestCompletion: (Result<ChannelPayload, Error>) -> Void = { [weak self] result in
830836
self?.callback { [weak self] in
831837
guard let self = self else { return }
@@ -1085,6 +1091,24 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
10851091
messages.removeAll { $0.id == deletedMessage.id }
10861092
}
10871093

1094+
private func softDeleteMessages(from userId: UserId, deletedAt: Date) {
1095+
let messagesWithDeletedMessages = messages.map { message in
1096+
if message.author.id == userId {
1097+
return message.changing(
1098+
deletedAt: deletedAt
1099+
)
1100+
}
1101+
return message
1102+
}
1103+
messages = messagesWithDeletedMessages
1104+
}
1105+
1106+
private func hardDeleteMessages(from userId: UserId) {
1107+
messages.removeAll { message in
1108+
message.author.id == userId
1109+
}
1110+
}
1111+
10881112
private func handleNewReaction(_ reactionEvent: ReactionNewEvent) {
10891113
updateMessage(reactionEvent.message)
10901114
}

Sources/StreamChat/Models/ChatMessage.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ extension ChatMessage: Hashable {
554554
guard lhs.id == rhs.id else { return false }
555555
guard lhs.localState == rhs.localState else { return false }
556556
guard lhs.updatedAt == rhs.updatedAt else { return false }
557+
guard lhs.deletedAt == rhs.deletedAt else { return false }
557558
guard lhs.allAttachments == rhs.allAttachments else { return false }
558559
guard lhs.poll == rhs.poll else { return false }
559560
guard lhs.author == rhs.author else { return false }

Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ struct UserChannelBanEventsMiddleware: EventMiddleware {
3030
memberDTO.isShadowBanned = false
3131
memberDTO.banExpiresAt = nil
3232

33+
case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO:
34+
let userId = userMessagesDeletedEvent.user.id
35+
if let userDTO = session.user(id: userId) {
36+
userDTO.messages?.forEach { message in
37+
if userMessagesDeletedEvent.payload.hardDelete {
38+
message.isHardDeleted = true
39+
} else {
40+
message.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate
41+
}
42+
}
43+
}
44+
3345
default:
3446
break
3547
}

Sources/StreamChat/WebSocketClient/Events/EventType.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public extension EventType {
3838
static let userBanned: Self = "user.banned"
3939
/// When a user was unbanned.
4040
static let userUnbanned: Self = "user.unbanned"
41+
/// When the messages of a banned user should be deleted.
42+
static let userMessagesDeleted: Self = "user.messages.deleted"
4143

4244
// MARK: Channel Events
4345

@@ -191,6 +193,8 @@ extension EventType {
191193
return try (try? UserBannedEventDTO(from: response)) ?? UserGloballyBannedEventDTO(from: response)
192194
case .userUnbanned:
193195
return try (try? UserUnbannedEventDTO(from: response)) ?? UserGloballyUnbannedEventDTO(from: response)
196+
case .userMessagesDeleted:
197+
return try UserMessagesDeletedEventDTO(from: response)
194198

195199
case .channelCreated: throw ClientError.IgnoredEventType()
196200
case .channelUpdated: return try ChannelUpdatedEventDTO(from: response)

Sources/StreamChat/WebSocketClient/Events/UserEvents.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,44 @@ class UserUnbannedEventDTO: EventDTO {
271271
)
272272
}
273273
}
274+
275+
/// Triggered when the messages of a banned user should be deleted.
276+
public struct UserMessagesDeletedEvent: Event {
277+
/// The banned user.
278+
public let user: ChatUser
279+
280+
/// If the messages should be hard deleted or not.
281+
public let hardDelete: Bool
282+
283+
/// The event timestamp
284+
public let createdAt: Date
285+
}
286+
287+
class UserMessagesDeletedEventDTO: EventDTO {
288+
let user: UserPayload
289+
let createdAt: Date
290+
let payload: EventPayload
291+
292+
init(from response: EventPayload) throws {
293+
user = try response.value(at: \.user)
294+
createdAt = try response.value(at: \.createdAt)
295+
payload = response
296+
}
297+
298+
func toDomainEvent(session: DatabaseSession) -> Event? {
299+
if let userDTO = session.user(id: user.id),
300+
let userModel = try? userDTO.asModel() {
301+
return UserMessagesDeletedEvent(
302+
user: userModel,
303+
hardDelete: payload.hardDelete,
304+
createdAt: createdAt
305+
)
306+
}
307+
308+
return UserMessagesDeletedEvent(
309+
user: user.asModel(),
310+
hardDelete: payload.hardDelete,
311+
createdAt: createdAt
312+
)
313+
}
314+
}

Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,6 +2299,183 @@ extension LivestreamChannelController_Tests {
22992299
XCTAssertEqual(controller.skippedMessagesAmount, 1)
23002300
XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused
23012301
}
2302+
2303+
func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteFalse_marksUserMessagesAsSoftDeleted() {
2304+
// Given
2305+
let bannedUserId = UserId.unique
2306+
let otherUserId = UserId.unique
2307+
let eventCreatedAt = Date()
2308+
2309+
// Add messages from both users
2310+
let bannedUserMessage1 = ChatMessage.mock(
2311+
id: "banned1",
2312+
cid: controller.cid!,
2313+
text: "Message from banned user 1",
2314+
author: .mock(id: bannedUserId)
2315+
)
2316+
let bannedUserMessage2 = ChatMessage.mock(
2317+
id: "banned2",
2318+
cid: controller.cid!,
2319+
text: "Message from banned user 2",
2320+
author: .mock(id: bannedUserId)
2321+
)
2322+
let otherUserMessage = ChatMessage.mock(
2323+
id: "other",
2324+
cid: controller.cid!,
2325+
text: "Message from other user",
2326+
author: .mock(id: otherUserId)
2327+
)
2328+
2329+
// Add messages to controller
2330+
controller.eventsController(
2331+
EventsController(notificationCenter: client.eventNotificationCenter),
2332+
didReceiveEvent: MessageNewEvent(
2333+
user: .mock(id: bannedUserId),
2334+
message: bannedUserMessage1,
2335+
channel: .mock(cid: controller.cid!),
2336+
createdAt: .unique,
2337+
watcherCount: nil,
2338+
unreadCount: nil
2339+
)
2340+
)
2341+
controller.eventsController(
2342+
EventsController(notificationCenter: client.eventNotificationCenter),
2343+
didReceiveEvent: MessageNewEvent(
2344+
user: .mock(id: bannedUserId),
2345+
message: bannedUserMessage2,
2346+
channel: .mock(cid: controller.cid!),
2347+
createdAt: .unique,
2348+
watcherCount: nil,
2349+
unreadCount: nil
2350+
)
2351+
)
2352+
controller.eventsController(
2353+
EventsController(notificationCenter: client.eventNotificationCenter),
2354+
didReceiveEvent: MessageNewEvent(
2355+
user: .mock(id: otherUserId),
2356+
message: otherUserMessage,
2357+
channel: .mock(cid: controller.cid!),
2358+
createdAt: .unique,
2359+
watcherCount: nil,
2360+
unreadCount: nil
2361+
)
2362+
)
2363+
2364+
XCTAssertEqual(controller.messages.count, 3)
2365+
XCTAssertNil(controller.messages.first { $0.id == "banned1" }?.deletedAt)
2366+
XCTAssertNil(controller.messages.first { $0.id == "banned2" }?.deletedAt)
2367+
XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt)
2368+
2369+
// When
2370+
let userMessagesDeletedEvent = UserMessagesDeletedEvent(
2371+
user: .mock(id: bannedUserId),
2372+
hardDelete: false,
2373+
createdAt: eventCreatedAt
2374+
)
2375+
controller.eventsController(
2376+
EventsController(notificationCenter: client.eventNotificationCenter),
2377+
didReceiveEvent: userMessagesDeletedEvent
2378+
)
2379+
2380+
// Then
2381+
XCTAssertEqual(controller.messages.count, 3) // Messages still present
2382+
2383+
// Banned user messages should be marked as deleted
2384+
XCTAssertEqual(controller.messages.first { $0.id == "banned1" }?.deletedAt, eventCreatedAt)
2385+
XCTAssertEqual(controller.messages.first { $0.id == "banned2" }?.deletedAt, eventCreatedAt)
2386+
2387+
// Other user message should not be affected
2388+
XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt)
2389+
}
2390+
2391+
func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteTrue_removesUserMessages() {
2392+
// Given
2393+
let bannedUserId = UserId.unique
2394+
let otherUserId = UserId.unique
2395+
let eventCreatedAt = Date()
2396+
2397+
// Add messages from both users
2398+
let bannedUserMessage1 = ChatMessage.mock(
2399+
id: "banned1",
2400+
cid: controller.cid!,
2401+
text: "Message from banned user 1",
2402+
author: .mock(id: bannedUserId)
2403+
)
2404+
let bannedUserMessage2 = ChatMessage.mock(
2405+
id: "banned2",
2406+
cid: controller.cid!,
2407+
text: "Message from banned user 2",
2408+
author: .mock(id: bannedUserId)
2409+
)
2410+
let otherUserMessage = ChatMessage.mock(
2411+
id: "other",
2412+
cid: controller.cid!,
2413+
text: "Message from other user",
2414+
author: .mock(id: otherUserId)
2415+
)
2416+
2417+
// Add messages to controller
2418+
controller.eventsController(
2419+
EventsController(notificationCenter: client.eventNotificationCenter),
2420+
didReceiveEvent: MessageNewEvent(
2421+
user: .mock(id: bannedUserId),
2422+
message: bannedUserMessage1,
2423+
channel: .mock(cid: controller.cid!),
2424+
createdAt: .unique,
2425+
watcherCount: nil,
2426+
unreadCount: nil
2427+
)
2428+
)
2429+
controller.eventsController(
2430+
EventsController(notificationCenter: client.eventNotificationCenter),
2431+
didReceiveEvent: MessageNewEvent(
2432+
user: .mock(id: bannedUserId),
2433+
message: bannedUserMessage2,
2434+
channel: .mock(cid: controller.cid!),
2435+
createdAt: .unique,
2436+
watcherCount: nil,
2437+
unreadCount: nil
2438+
)
2439+
)
2440+
controller.eventsController(
2441+
EventsController(notificationCenter: client.eventNotificationCenter),
2442+
didReceiveEvent: MessageNewEvent(
2443+
user: .mock(id: otherUserId),
2444+
message: otherUserMessage,
2445+
channel: .mock(cid: controller.cid!),
2446+
createdAt: .unique,
2447+
watcherCount: nil,
2448+
unreadCount: nil
2449+
)
2450+
)
2451+
2452+
XCTAssertEqual(controller.messages.count, 3)
2453+
XCTAssertNotNil(controller.messages.first { $0.id == "banned1" })
2454+
XCTAssertNotNil(controller.messages.first { $0.id == "banned2" })
2455+
XCTAssertNotNil(controller.messages.first { $0.id == "other" })
2456+
2457+
// When
2458+
let userMessagesDeletedEvent = UserMessagesDeletedEvent(
2459+
user: .mock(id: bannedUserId),
2460+
hardDelete: true,
2461+
createdAt: eventCreatedAt
2462+
)
2463+
controller.eventsController(
2464+
EventsController(notificationCenter: client.eventNotificationCenter),
2465+
didReceiveEvent: userMessagesDeletedEvent
2466+
)
2467+
2468+
// Then
2469+
XCTAssertEqual(controller.messages.count, 1) // Only other user's message remains
2470+
2471+
// Banned user messages should be completely removed
2472+
XCTAssertNil(controller.messages.first { $0.id == "banned1" })
2473+
XCTAssertNil(controller.messages.first { $0.id == "banned2" })
2474+
2475+
// Other user message should remain unaffected
2476+
XCTAssertNotNil(controller.messages.first { $0.id == "other" })
2477+
XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt)
2478+
}
23022479
}
23032480

23042481
// MARK: - Message CRUD Tests

0 commit comments

Comments
 (0)