Skip to content

Commit eda55a7

Browse files
authored
Add support for deleting messages only for the current user (#3836)
* Add `deletedForMe` property to `ChatMessage` * Handle `deletedForMe` in `MessageDeletedEvent` * Add `ChatMessageController.deleteMessageForMe()` * Fix `ChatMessage.isDeleted` not accounting for `deletedForMe` * Add demo app example to test the feature * Update CHANGELOG.md * Fix forgotten deletedForMe in ChatMessage Equality * Only update deletedForMe in MessageDTO if payload exists
1 parent 59bc3b2 commit eda55a7

File tree

32 files changed

+521
-19
lines changed

32 files changed

+521
-19
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ 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 deleting messages only for the current user [#3836](https://github.com/GetStream/stream-chat-swift/pull/3836)
9+
- Add `ChatMessageController.deleteMessageForMe()`
10+
- Add `ChatMessage.deletedForMe` 
711

812
# [4.90.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.90.0)
913
_October 07, 2025_

DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC {
1414
if message?.isSentByCurrentUser == true {
1515
if AppConfig.shared.demoAppConfig.isHardDeleteEnabled {
1616
actions.append(hardDeleteActionItem())
17+
actions.append(deleteForMeActionItem())
1718
}
1819
}
1920

@@ -108,6 +109,22 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC {
108109
)
109110
}
110111

112+
func deleteForMeActionItem() -> ChatMessageActionItem {
113+
DeleteForMeActionItem(
114+
action: { [weak self] _ in
115+
guard let self = self else { return }
116+
self.alertsRouter.showMessageDeletionConfirmationAlert { confirmed in
117+
guard confirmed else { return }
118+
119+
self.messageController.deleteMessageForMe { _ in
120+
self.delegate?.chatMessageActionsVCDidFinish(self)
121+
}
122+
}
123+
},
124+
appearance: appearance
125+
)
126+
}
127+
111128
func translateActionItem() -> ChatMessageActionItem {
112129
TranslateActionitem(
113130
action: { [weak self] _ in
@@ -224,6 +241,21 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC {
224241
}
225242
}
226243

244+
struct DeleteForMeActionItem: ChatMessageActionItem {
245+
var title: String { "Delete only for me" }
246+
var isDestructive: Bool { true }
247+
let icon: UIImage
248+
let action: (ChatMessageActionItem) -> Void
249+
250+
init(
251+
action: @escaping (ChatMessageActionItem) -> Void,
252+
appearance: Appearance = .default
253+
) {
254+
self.action = action
255+
icon = appearance.images.messageActionDelete
256+
}
257+
}
258+
227259
struct TranslateActionitem: ChatMessageActionItem {
228260
var title: String { "Translate to Turkish" }
229261
var isDestructive: Bool { false }

DemoApp/StreamChat/Components/DemoChatMessageContentView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ final class DemoChatMessageContentView: ChatMessageContentView {
7575
timestampLabel?.text?.append(" - Translated to Turkish")
7676
}
7777

78+
if content?.deletedForMe == true {
79+
timestampLabel?.text?.append(" - Deleted only for me")
80+
}
81+
7882
if content?.isPinned == true, let pinInfoLabel = pinInfoLabel {
7983
pinInfoLabel.text = "📌 Pinned"
8084
if let pinDetails = content?.pinDetails {

Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ extension Endpoint {
1515
)
1616
}
1717

18-
static func deleteMessage(messageId: MessageId, hard: Bool) -> Endpoint<MessagePayload.Boxed> {
19-
.init(
18+
static func deleteMessage(messageId: MessageId, hard: Bool, deleteForMe: Bool? = nil) -> Endpoint<MessagePayload.Boxed> {
19+
var body: [String: AnyEncodable] = ["hard": AnyEncodable(hard)]
20+
if let deleteForMe = deleteForMe {
21+
body["delete_for_me"] = AnyEncodable(deleteForMe)
22+
}
23+
return .init(
2024
path: .deleteMessage(messageId),
2125
method: .delete,
2226
queryItems: nil,
2327
requiresConnectionId: false,
24-
body: ["hard": hard]
28+
body: body
2529
)
2630
}
2731

Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable {
5757
case location = "shared_location"
5858
case reminder
5959
case member
60+
case deletedForMe = "deleted_for_me"
6061
}
6162

6263
extension MessagePayload {
@@ -119,6 +120,7 @@ class MessagePayload: Decodable {
119120
var location: SharedLocationPayload?
120121
var reminder: ReminderPayload?
121122
var member: MemberInfoPayload?
123+
let deletedForMe: Bool?
122124

123125
/// Only message payload from `getMessage` endpoint contains channel data. It's a convenience workaround for having to
124126
/// make an extra call do get channel details.
@@ -189,6 +191,7 @@ class MessagePayload: Decodable {
189191
location = try container.decodeIfPresent(SharedLocationPayload.self, forKey: .location)
190192
reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder)
191193
member = try container.decodeIfPresent(MemberInfoPayload.self, forKey: .member)
194+
deletedForMe = try container.decodeIfPresent(Bool.self, forKey: .deletedForMe)
192195
}
193196

194197
init(
@@ -233,7 +236,8 @@ class MessagePayload: Decodable {
233236
draft: DraftPayload? = nil,
234237
reminder: ReminderPayload? = nil,
235238
location: SharedLocationPayload? = nil,
236-
member: MemberInfoPayload? = nil
239+
member: MemberInfoPayload? = nil,
240+
deletedForMe: Bool? = nil
237241
) {
238242
self.id = id
239243
self.cid = cid
@@ -277,6 +281,7 @@ class MessagePayload: Decodable {
277281
self.location = location
278282
self.reminder = reminder
279283
self.member = member
284+
self.deletedForMe = deletedForMe
280285
}
281286
}
282287

Sources/StreamChat/Controllers/MessageController/MessageController.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,26 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP
355355
}
356356
}
357357

358+
/// Deletes the message this controller manages only for the current user.
359+
///
360+
/// This method deletes the message only for the current user, making it invisible to them while keeping it visible to other users.
361+
/// This is different from the regular delete which affects all users in the channel.
362+
///
363+
/// - Parameter completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
364+
/// If request fails, the completion will be called with an error.
365+
///
366+
public func deleteMessageForMe(completion: ((Error?) -> Void)? = nil) {
367+
messageUpdater.deleteMessage(
368+
messageId: messageId,
369+
hard: false,
370+
deleteForMe: true
371+
) { error in
372+
self.callback {
373+
completion?(error)
374+
}
375+
}
376+
}
377+
358378
/// Creates a new reply message locally and schedules it for send.
359379
///
360380
/// - Parameters:

Sources/StreamChat/Database/DTOs/MessageDTO.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class MessageDTO: NSManagedObject {
118118
@NSManaged var defaultSortingKey: DBDate!
119119

120120
@NSManaged var channelRole: String?
121+
@NSManaged var deletedForMe: Bool
121122

122123
override func willSave() {
123124
super.willSave()
@@ -990,6 +991,10 @@ extension NSManagedObjectContext: MessageDatabaseSession {
990991

991992
dto.isSilent = payload.isSilent
992993
dto.isShadowed = payload.isShadowed
994+
if let deletedForMe = payload.deletedForMe {
995+
dto.deletedForMe = deletedForMe
996+
}
997+
993998
// Due to backend not working as advertised
994999
// (sending `shadowed: true` flag to the shadow banned user)
9951000
// we have to implement this workaround to get the advertised behavior
@@ -1776,6 +1781,7 @@ private extension ChatMessage {
17761781
let isBounced = dto.isBounced
17771782
let isSilent = dto.isSilent
17781783
let isShadowed = dto.isShadowed
1784+
let deletedForMe = dto.deletedForMe
17791785
let reactionScores = dto.reactionScores.mapKeys { MessageReactionType(rawValue: $0) }
17801786
let reactionCounts = dto.reactionCounts.mapKeys { MessageReactionType(rawValue: $0) }
17811787
let reactionGroups = dto.reactionGroups.asModel()
@@ -1890,6 +1896,7 @@ private extension ChatMessage {
18901896
isBounced: isBounced,
18911897
isSilent: isSilent,
18921898
isShadowed: isShadowed,
1899+
deletedForMe: deletedForMe,
18931900
reactionScores: reactionScores,
18941901
reactionCounts: reactionCounts,
18951902
reactionGroups: reactionGroups,

Sources/StreamChat/Database/DatabaseSession.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,11 @@ extension DatabaseSession {
842842
return
843843
}
844844

845+
// Update the message if deleted only for the current user.
846+
if payload.eventType == .messageDeleted && payload.deletedForMe == true {
847+
savedMessage.deletedForMe = true
848+
}
849+
845850
// When a message is updated, make sure to update
846851
// the messages quoting the edited message by triggering a DB Update.
847852
if payload.eventType == .messageUpdated {

Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@
219219
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
220220
<attribute name="defaultSortingKey" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
221221
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
222+
<attribute name="deletedForMe" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
222223
<attribute name="extraData" optional="YES" attributeType="Binary"/>
223224
<attribute name="id" attributeType="String"/>
224225
<attribute name="isActiveLiveLocation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>

Sources/StreamChat/Models/ChatMessage.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ public struct ChatMessage {
8585
///
8686
public let isShadowed: Bool
8787

88+
/// A flag indicating whether the message was deleted only for the current user.
89+
///
90+
/// Messages with this flag set to true are deleted for the current user but still visible to others.
91+
public let deletedForMe: Bool
92+
8893
/// The reactions to the message created by any user.
8994
public let reactionScores: [MessageReactionType: Int]
9095

@@ -206,6 +211,7 @@ public struct ChatMessage {
206211
isBounced: Bool,
207212
isSilent: Bool,
208213
isShadowed: Bool,
214+
deletedForMe: Bool,
209215
reactionScores: [MessageReactionType: Int],
210216
reactionCounts: [MessageReactionType: Int],
211217
reactionGroups: [MessageReactionType: ChatMessageReactionGroup],
@@ -248,6 +254,7 @@ public struct ChatMessage {
248254
self.isBounced = isBounced
249255
self.isSilent = isSilent
250256
self.isShadowed = isShadowed
257+
self.deletedForMe = deletedForMe
251258
self.reactionScores = reactionScores
252259
self.reactionCounts = reactionCounts
253260
self.reactionGroups = reactionGroups
@@ -312,6 +319,7 @@ public struct ChatMessage {
312319
isBounced: isBounced,
313320
isSilent: isSilent,
314321
isShadowed: isShadowed,
322+
deletedForMe: deletedForMe,
315323
reactionScores: reactionScores,
316324
reactionCounts: reactionCounts,
317325
reactionGroups: reactionGroups,
@@ -427,6 +435,7 @@ public struct ChatMessage {
427435
isBounced: isBounced,
428436
isSilent: isSilent,
429437
isShadowed: isShadowed,
438+
deletedForMe: deletedForMe,
430439
reactionScores: reactionScores,
431440
reactionCounts: reactionCounts,
432441
reactionGroups: reactionGroups,
@@ -562,6 +571,7 @@ extension ChatMessage: Hashable {
562571
guard lhs.localState == rhs.localState else { return false }
563572
guard lhs.updatedAt == rhs.updatedAt else { return false }
564573
guard lhs.deletedAt == rhs.deletedAt else { return false }
574+
guard lhs.deletedForMe == rhs.deletedForMe else { return false }
565575
guard lhs.allAttachments == rhs.allAttachments else { return false }
566576
guard lhs.poll == rhs.poll else { return false }
567577
guard lhs.author == rhs.author else { return false }

0 commit comments

Comments
 (0)