Repository: GetStream/stream-chat-swift
Pull request #3671: Add `SendMessageInterceptor` to intercept send message request
Author: login: nuno-vieira
Created: 2025-05-19T15:34:49Z | Merged: 2025-05-27T10:09:07Z

### 🔗 Issue Links

IOS-857

### 🎯 Goal

Add a way to intercept our send message request

### 📝 Summary

- Add `SendMessageInterceptor`
- Add `SendMessageInterceptorFactory`
- Add `ChatMessage.changing()`
- Improve documentation of `ChatMessage.replacing()`

### 🛠 Implementation

#### `SendMessageInterceptor`
A new protocol has been introduced, `SendMessageInterceptor`. This will only be used if the customer provides an interceptor. If no interceptor is provided, nothing is changed.

The `SendMessageInterceptor` is not exposed directly in the `ChatClientConfig`. Instead,  it can be injected through a `SendMessageInterceptorFactory`. The factory gives us more flexibility, especially to make it possible to inject the `ChatClient`, which can be useful in case a customer wants to use the interceptor but still wants to use our internal APIClient to make the request.  I created an example on how, in the future, we can provide this to customers [here](https://github.com/GetStream/stream-chat-swift/pull/3671/commits/3985f12566b27b3a70ed563ba76fb34d5540a670). For now, I reverted it, but if any customer needs to use our APIClient, it will be easy to provide it. It can be useful for customers who might want to intercept our message requests, but only for a certain type of messages, or simply want to override some data.

#### `ChatMessage.changing()` vs `ChatMessage.replacing()`
I added documentation to `ChatMessage.replacing()` since it was lacking, and it could be misleading on how to use it. The `replacing()` function is useful when overriding data, especially if the customer wants to delete some of it. For example, providing the `state` with `nil` will make the message state as published. Since this behaviour might not be what some customers need, a `changing()` function has been introduced for the case where customers just want to change some data, but the rest should be left unchanged (It is different from `replacing()` because in this case it is not possible to erase data, since `nil` will fallback to the original data). 

### 🧪 Manual Testing Notes

N/A. Covered by unit tests.

### ☑️ Contributor Checklist

- [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required)
- [x] This change should be manually QAed
- [x] Changelog is updated with client-facing changes
- [ ] Changelog is updated with new localization keys
- [x] New code is covered by unit tests
- [ ] Documentation has been updated in the `docs-content` repo



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cee379e2b3..1d7b5ba11ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 # Upcoming
 
 ## StreamChat
+### ✅ Added
+- Add `SendMessageInterceptor` to intercept send message requests [#3671](https://github.com/GetStream/stream-chat-swift/pull/3671)
+- Add `ChatMessage.changing()` to allow overriding message data temporarily [#3671](https://github.com/GetStream/stream-chat-swift/pull/3671)
 ### 🐞 Fixed
 - Fix swipe to reply enabled when quoting a message is disabled [#3662](https://github.com/GetStream/stream-chat-swift/pull/3662)
 - Fix shadowed messages increasing the channel messages unread count [#3665](https://github.com/GetStream/stream-chat-swift/pull/3665)
diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift
index 9048ea8202b..403cbcbea3a 100644
--- a/Sources/StreamChat/ChatClient.swift
+++ b/Sources/StreamChat/ChatClient.swift
@@ -222,6 +222,9 @@ public class ChatClient {
         setupConnectionRecoveryHandler(with: environment)
         validateIntegrity()
 
+        let interceptor = config.sendMessageInterceptorFactory?.makeSendMessageInterceptor(client: self)
+        messageRepository.setInterceptor(interceptor)
+
         reconnectionTimeoutHandler = environment.reconnectionHandlerBuilder(config)
         reconnectionTimeoutHandler?.onChange = { [weak self] in
             self?.timeout()
diff --git a/Sources/StreamChat/Config/ChatClientConfig.swift b/Sources/StreamChat/Config/ChatClientConfig.swift
index be56244de93..7ce758736e1 100644
--- a/Sources/StreamChat/Config/ChatClientConfig.swift
+++ b/Sources/StreamChat/Config/ChatClientConfig.swift
@@ -67,6 +67,10 @@ public struct ChatClientConfig {
     /// An object that provides a way to transform Stream Chat models.
     public var modelsTransformer: StreamModelsTransformer?
 
+    /// A factory to create an interceptor with the goal of intercepting messages
+    /// before sending them to the server.
+    public var sendMessageInterceptorFactory: SendMessageInterceptorFactory?
+
     /// Advanced settings for the local caching and model serialization.
     public var localCaching = LocalCaching()
 
diff --git a/Sources/StreamChat/Config/SendMessageInterceptor.swift b/Sources/StreamChat/Config/SendMessageInterceptor.swift
new file mode 100644
index 00000000000..77ca35e83a0
--- /dev/null
+++ b/Sources/StreamChat/Config/SendMessageInterceptor.swift
@@ -0,0 +1,34 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+/// A struct that contains additional info when sending messages.
+public struct SendMessageOptions {
+    public let skipPush: Bool
+    public let skipEnrichUrl: Bool
+}
+
+/// A struct that represents the response when sending a message.
+public struct SendMessageResponse {
+    public let message: ChatMessage
+
+    public init(message: ChatMessage) {
+        self.message = message
+    }
+}
+
+/// A protocol that defines a way to intercept messages before sending them to the server.
+public protocol SendMessageInterceptor {
+    func sendMessage(
+        _ message: ChatMessage,
+        options: SendMessageOptions,
+        completion: @escaping ((Result<SendMessageResponse, Error>) -> Void)
+    )
+}
+
+/// A factory responsible for creating message interceptors.
+public protocol SendMessageInterceptorFactory {
+    func makeSendMessageInterceptor(client: ChatClient) -> SendMessageInterceptor
+}
diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift
index f75be99d7f1..1aaa575ec65 100644
--- a/Sources/StreamChat/Models/ChatMessage.swift
+++ b/Sources/StreamChat/Models/ChatMessage.swift
@@ -261,11 +261,132 @@ public struct ChatMessage {
         self.draftReply = draftReply
     }
 
+    /// Returns a new `ChatMessage` with the provided data changed.
+    ///
+    /// If the provided data is `nil`, it will keep the original value.
+    public func changing(
+        text: String? = nil,
+        type: MessageType? = nil,
+        state: LocalMessageState? = nil,
+        command: String? = nil,
+        arguments: String? = nil,
+        attachments: [AnyChatMessageAttachment]? = nil,
+        translations: [TranslationLanguage: String]? = nil,
+        originalLanguage: TranslationLanguage? = nil,
+        moderationDetails: MessageModerationDetails? = nil,
+        extraData: [String: RawJSON]? = nil
+    ) -> ChatMessage {
+        .init(
+            id: id,
+            cid: cid,
+            text: text ?? self.text,
+            type: type ?? self.type,
+            command: command ?? self.command,
+            createdAt: createdAt,
+            locallyCreatedAt: locallyCreatedAt,
+            updatedAt: updatedAt,
+            deletedAt: deletedAt,
+            arguments: arguments ?? self.arguments,
+            parentMessageId: parentMessageId,
+            showReplyInChannel: showReplyInChannel,
+            replyCount: replyCount,
+            extraData: extraData ?? self.extraData,
+            quotedMessage: quotedMessage,
+            isBounced: isBounced,
+            isSilent: isSilent,
+            isShadowed: isShadowed,
+            reactionScores: reactionScores,
+            reactionCounts: reactionCounts,
+            reactionGroups: reactionGroups,
+            author: author,
+            mentionedUsers: mentionedUsers,
+            threadParticipants: threadParticipants,
+            attachments: attachments ?? allAttachments,
+            latestReplies: latestReplies,
+            localState: state ?? localState,
+            isFlaggedByCurrentUser: isFlaggedByCurrentUser,
+            latestReactions: latestReactions,
+            currentUserReactions: currentUserReactions,
+            isSentByCurrentUser: isSentByCurrentUser,
+            pinDetails: pinDetails,
+            translations: translations ?? self.translations,
+            originalLanguage: originalLanguage ?? self.originalLanguage,
+            moderationDetails: moderationDetails ?? self.moderationDetails,
+            readBy: readBy,
+            poll: poll,
+            textUpdatedAt: textUpdatedAt,
+            draftReply: draftReply
+        )
+    }
+
     /// Returns a new `ChatMessage` with the provided data replaced.
+    ///
+    /// If the provided data is `nil`, it will overwrite the existing value with `nil`.
+    /// If you want to keep the original data, use the original object data, example:
+    ///
+    /// ```swift
+    /// /// Updating only the text of the message
+    /// let newMessage = oldMessage.replacing(
+    ///     text: "New text"
+    ///     extraData: oldMessage.extraData,
+    ///     attachments: oldMessage.attachments
+    /// )
+    ///
+    /// /// Updating the text and removing the attachments
+    /// let newMessage = oldMessage.replacing(
+    ///    text: "New text",
+    ///    extraData: oldMessage.extraData,
+    ///    attachments: nil
+    /// )
+    /// ```
     public func replacing(
         text: String?,
         extraData: [String: RawJSON]?,
         attachments: [AnyChatMessageAttachment]?
+    ) -> ChatMessage {
+        replacing(
+            text: text,
+            type: type,
+            state: localState,
+            command: command,
+            arguments: arguments,
+            attachments: attachments,
+            translations: translations,
+            originalLanguage: originalLanguage,
+            moderationDetails: moderationDetails,
+            extraData: extraData
+        )
+    }
+    
+    /// Returns a new `ChatMessage` with the provided data replaced.
+    ///
+    /// If the provided data is `nil`, it will overwrite the existing value with `nil`.
+    /// If you want to keep the original data, use the original object data.
+    ///
+    /// - Parameters:
+    ///   - text: The new text of the message. If `nil`, the text will be empty.
+    ///   - type: The new type of the message. If `nil`, the type will be `.regular`.
+    ///   - state: The new local state of the message. If `nil`, the local state will be empty
+    ///   which means the message is published to the server.
+    ///   - command: The new command of the message. If `nil`, the command will be empty.
+    ///   - arguments: The new arguments of the message. If `nil`, the arguments will be empty.
+    ///   - attachments: The new attachments of the message. If `nil`, the attachments will be empty.
+    ///   - translations: The new translations of the message. If `nil`, the translations will be empty.
+    ///   - originalLanguage: The original language of the message. If `nil`, the original language will be empty.
+    ///   - moderationDetails: The new moderation details of the message. If `nil`, the moderation details will be empty.
+    ///   - extraData: The new extra data of the message. If `nil`, the extra data will be empty.
+    /// - Returns: The message with the updated data.
+    public func replacing(
+        text: String?,
+        type: MessageType,
+        state: LocalMessageState?,
+        command: String?,
+        arguments: String?,
+        attachments: [AnyChatMessageAttachment]?,
+        translations: [TranslationLanguage: String]?,
+        originalLanguage: TranslationLanguage?,
+        moderationDetails: MessageModerationDetails?,
+        extraData: [String: RawJSON]?
     ) -> ChatMessage {
         .init(
             id: id,
@@ -294,7 +415,7 @@ public struct ChatMessage {
             threadParticipants: threadParticipants,
             attachments: attachments ?? [],
             latestReplies: latestReplies,
-            localState: localState,
+            localState: state,
             isFlaggedByCurrentUser: isFlaggedByCurrentUser,
             latestReactions: latestReactions,
             currentUserReactions: currentUserReactions,
diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift
index 392dad7b482..4d11eda2838 100644
--- a/Sources/StreamChat/Repositories/MessageRepository.swift
+++ b/Sources/StreamChat/Repositories/MessageRepository.swift
@@ -5,7 +5,7 @@
 import CoreData
 import Foundation
 
-enum MessageRepositoryError: LocalizedError {
+enum MessageRepositoryError: Error {
     case messageDoesNotExist
     case messageNotPendingSend
     case messageDoesNotHaveValidChannel
@@ -15,12 +15,20 @@ enum MessageRepositoryError: LocalizedError {
 class MessageRepository {
     let database: DatabaseContainer
     let apiClient: APIClient
+    var interceptor: SendMessageInterceptor?
 
-    init(database: DatabaseContainer, apiClient: APIClient) {
+    init(
+        database: DatabaseContainer,
+        apiClient: APIClient
+    ) {
         self.database = database
         self.apiClient = apiClient
     }
 
+    func setInterceptor(_ interceptor: SendMessageInterceptor?) {
+        self.interceptor = interceptor
+    }
+
     func sendMessage(
         with messageId: MessageId,
         completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
@@ -51,38 +59,40 @@ class MessageRepository {
             let skipEnrichUrl: Bool = dto.skipEnrichUrl
 
             // Change the message state to `.sending` and the proceed with the actual sending
-            self?.database.write({
-                let messageDTO = $0.message(id: messageId)
+            self?.database.write(converting: { session in
+                let messageDTO = session.message(id: messageId)
                 messageDTO?.localMessageState = .sending
-            }, completion: { error in
-                if let error = error {
-                    log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
-                    self?.markMessageAsFailedToSend(id: messageId) {
-                        completion(.failure(.failedToSendMessage(error)))
-                    }
-                    return
+                guard let message = try messageDTO?.asModel() else {
+                    throw MessageRepositoryError.messageDoesNotExist
                 }
-
-                let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
-                    cid: cid,
-                    messagePayload: requestBody,
-                    skipPush: skipPush,
-                    skipEnrichUrl: skipEnrichUrl
-                )
-                self?.apiClient.request(endpoint: endpoint) {
-                    switch $0 {
-                    case let .success(payload):
-                        self?.saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
-                            switch result {
-                            case let .success(message):
-                                completion(.success(message))
-                            case let .failure(error):
-                                completion(.failure(.failedToSendMessage(error)))
-                            }
+                return message
+            }, completion: { result in
+                switch result {
+                case let .success(message):
+                    if let interceptor = self?.interceptor {
+                        let options = SendMessageOptions(
+                            skipPush: skipPush,
+                            skipEnrichUrl: skipEnrichUrl
+                        )
+                        interceptor.sendMessage(message, options: options) { result in
+                            self?.handleInterceptedMessage(result, messageId: messageId, completion: completion)
                         }
+                        return
+                    }
 
-                    case let .failure(error):
-                        self?.handleSendingMessageError(error, messageId: messageId, completion: completion)
+                    let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
+                        cid: cid,
+                        messagePayload: requestBody,
+                        skipPush: skipPush,
+                        skipEnrichUrl: skipEnrichUrl
+                    )
+                    self?.apiClient.request(endpoint: endpoint) { result in
+                        self?.handleSentMessage(result, cid: cid, messageId: messageId, completion: completion)
+                    }
+                case let .failure(error):
+                    log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
+                    self?.markMessageAsFailedToSend(id: messageId) {
+                        completion(.failure(.failedToSendMessage(error)))
                     }
                 }
             })
@@ -158,6 +168,59 @@ class MessageRepository {
         })
     }
 
+    /// Handles the result when sending the message to the server.
+    private func handleSentMessage(
+        _ result: Result<MessagePayload.Boxed, Error>,
+        cid: ChannelId,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(payload):
+            saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
+                switch result {
+                case let .success(message):
+                    completion(.success(message))
+                case let .failure(error):
+                    completion(.failure(.failedToSendMessage(error)))
+                }
+            }
+        case let .failure(error):
+            handleSendingMessageError(
+                error,
+                messageId: messageId,
+                completion: completion
+            )
+        }
+    }
+
+    /// Handles the result when the message is intercepted.
+    private func handleInterceptedMessage(
+        _ result: Result<SendMessageResponse, Error>,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(response):
+            let message = response.message
+            database.write { session in
+                guard let messageDTO = session.message(id: message.id) else { return }
+                // The customer changes the local state to nil in the interceptor,
+                // it means we should mark it as sent and not wait for message new event.
+                if message.localState == nil {
+                    messageDTO.markMessageAsSent()
+                }
+            }
+            completion(.success(message))
+        case let .failure(error):
+            handleSendingMessageError(
+                error,
+                messageId: messageId,
+                completion: completion
+            )
+        }
+    }
+
     private func handleSendingMessageError(
         _ error: Error,
         messageId: MessageId,
diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj
index b2d9a14d7c5..c3e8de79df8 100644
--- a/StreamChat.xcodeproj/project.pbxproj
+++ b/StreamChat.xcodeproj/project.pbxproj
@@ -1665,6 +1665,8 @@
 		AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; };
 		AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; };
 		AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; };
+		AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; };
+		AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; };
 		ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; };
 		ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; };
 		ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */; };
@@ -4381,6 +4383,7 @@
 		AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = "<group>"; };
 		AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = "<group>"; };
 		AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
+		AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageInterceptor.swift; sourceTree = "<group>"; };
 		ADA03A212D64EFE900DFE048 /* DraftMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage.swift; sourceTree = "<group>"; };
 		ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage_Mock.swift; sourceTree = "<group>"; };
 		ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = "<group>"; };
@@ -5715,6 +5718,7 @@
 				79FC85E624ACCBC500A665ED /* Token.swift */,
 				C1B49B3C2822A7AD00F4E89E /* StreamRuntimeCheck.swift */,
 				AD81FEEC2D3977AC00765FD4 /* StreamModelsTransformer.swift */,
+				AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */,
 			);
 			path = Config;
 			sourceTree = "<group>";
@@ -11537,6 +11541,7 @@
 				AD52A2192804850700D0157E /* ChannelConfigDTO.swift in Sources */,
 				797A756824814F0D003CF16D /* Bundle+Extensions.swift in Sources */,
 				7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */,
+				AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */,
 				DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */,
 				64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */,
 				C1FFD9F927ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */,
@@ -12588,6 +12593,7 @@
 				C121E8C6274544B100023E4C /* ChannelWatcherListQuery.swift in Sources */,
 				4F05C0722C8832C40085B4B7 /* URLRequest+cURL.swift in Sources */,
 				AD37D7CB2BC98A5300800D8C /* ThreadReadDTO.swift in Sources */,
+				AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */,
 				C121E8C7274544B100023E4C /* ChannelListQuery.swift in Sources */,
 				841BAA0B2BCE9B57000C73E4 /* CreatePollOptionRequestBody.swift in Sources */,
 				C121E8C8274544B100023E4C /* UserListQuery.swift in Sources */,
diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
index 6b0254ce825..8dadbb8760b 100644
--- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
+++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift
@@ -436,4 +436,154 @@ final class ChatMessage_Tests: XCTestCase {
         XCTAssertEqual(partialReplacement.extraData, [:])
         XCTAssertEqual(partialReplacement.allAttachments, [])
     }
+    
+    func test_replacing_allParameters() {
+        // Create a mock message with initial values
+        let originalMessage = ChatMessage.mock(
+            id: .unique,
+            cid: .unique,
+            text: "Original text",
+            type: .regular,
+            command: "original-command",
+            arguments: "original-arguments",
+            extraData: ["original": .string("data")],
+            translations: [.english: "Original text"],
+            originalLanguage: .french,
+            moderationsDetails: nil,
+            attachments: [
+                .dummy(id: .init(cid: .unique, messageId: .unique, index: 0))
+            ],
+            localState: .pendingSend
+        )
+
+        // Test replacing all available fields
+        let allFieldsReplaced = originalMessage.replacing(
+            text: "New text",
+            type: .reply,
+            state: .sending,
+            command: "new-command",
+            arguments: "new-arguments",
+            attachments: [
+                .dummy(id: .init(cid: .unique, messageId: .unique, index: 99))
+            ],
+            translations: [.spanish: "Texto nuevo"],
+            originalLanguage: .german,
+            moderationDetails: nil,
+            extraData: ["new": .string("data")]
+        )
+        
+        // Verify all replaced fields
+        XCTAssertEqual(allFieldsReplaced.text, "New text")
+        XCTAssertEqual(allFieldsReplaced.type, .reply)
+        XCTAssertEqual(allFieldsReplaced.localState, .sending)
+        XCTAssertEqual(allFieldsReplaced.command, "new-command")
+        XCTAssertEqual(allFieldsReplaced.arguments, "new-arguments")
+        XCTAssertEqual(allFieldsReplaced.extraData["new"]?.stringValue, "data")
+        XCTAssertEqual(allFieldsReplaced.allAttachments.first?.id.index, 99)
+        XCTAssertEqual(allFieldsReplaced.translations?[.spanish], "Texto nuevo")
+        XCTAssertEqual(allFieldsReplaced.originalLanguage, .german)
+        XCTAssertNil(allFieldsReplaced.moderationDetails)
+
+        // Verify fields that should remain unchanged
+        XCTAssertEqual(allFieldsReplaced.id, originalMessage.id)
+        XCTAssertEqual(allFieldsReplaced.cid, originalMessage.cid)
+        XCTAssertEqual(allFieldsReplaced.author, originalMessage.author)
+        XCTAssertEqual(allFieldsReplaced.createdAt, originalMessage.createdAt)
+        
+        // Test replacing with nil values (should clear the fields)
+        let nilValuesReplacement = originalMessage.replacing(
+            text: nil,
+            type: .regular,
+            state: nil,
+            command: nil,
+            arguments: nil,
+            attachments: nil,
+            translations: nil,
+            originalLanguage: nil,
+            moderationDetails: nil,
+            extraData: nil
+        )
+        
+        // Verify fields are cleared
+        XCTAssertEqual(nilValuesReplacement.text, "")
+        XCTAssertEqual(nilValuesReplacement.command, nil)
+        XCTAssertEqual(nilValuesReplacement.arguments, nil)
+        XCTAssertEqual(nilValuesReplacement.extraData, [:])
+        XCTAssertEqual(nilValuesReplacement.allAttachments, [])
+        XCTAssertEqual(nilValuesReplacement.translations, nil)
+        XCTAssertEqual(nilValuesReplacement.originalLanguage, nil)
+        XCTAssertNil(nilValuesReplacement.moderationDetails)
+    }
+    
+    func test_changing() {
+        // Create a mock message with initial values
+        let originalMessage = ChatMessage.mock(
+            id: .unique,
+            cid: .unique,
+            text: "Original text",
+            type: .regular,
+            command: "original-command",
+            arguments: "original-arguments",
+            extraData: ["original": .string("data")],
+            translations: [.english: "Original text"],
+            originalLanguage: .french,
+            moderationsDetails: nil,
+            attachments: [
+                .dummy(id: .init(cid: .unique, messageId: .unique, index: 0))
+            ]
+        )
+
+        // Test changing only some fields
+        let partiallyChangedMessage = originalMessage.changing(
+            text: "New text",
+            type: .reply,
+            command: "new-command"
+        )
+        
+        // Verify changed fields
+        XCTAssertEqual(partiallyChangedMessage.text, "New text")
+        XCTAssertEqual(partiallyChangedMessage.type, .reply)
+        XCTAssertEqual(partiallyChangedMessage.command, "new-command")
+        
+        // Verify unchanged fields
+        XCTAssertEqual(partiallyChangedMessage.arguments, originalMessage.arguments)
+        XCTAssertEqual(partiallyChangedMessage.extraData, originalMessage.extraData)
+        XCTAssertEqual(partiallyChangedMessage.allAttachments, originalMessage.allAttachments)
+        XCTAssertEqual(partiallyChangedMessage.translations, originalMessage.translations)
+        XCTAssertEqual(partiallyChangedMessage.originalLanguage, originalMessage.originalLanguage)
+        XCTAssertNil(partiallyChangedMessage.moderationDetails)
+
+        // Test changing all available fields
+        let translations: [TranslationLanguage: String] = [.spanish: "Texto nuevo"]
+        let fullyChangedMessage = originalMessage.changing(
+            text: "New text",
+            type: .reply,
+            state: .sending,
+            command: "new-command",
+            arguments: "new-arguments",
+            attachments: [
+                .dummy(id: .init(cid: .unique, messageId: .unique, index: 99))
+            ],
+            translations: translations,
+            originalLanguage: .german,
+            moderationDetails: nil,
+            extraData: ["new": .string("data")]
+        )
+        
+        // Verify all changed fields
+        XCTAssertEqual(fullyChangedMessage.text, "New text")
+        XCTAssertEqual(fullyChangedMessage.type, .reply)
+        XCTAssertEqual(fullyChangedMessage.localState, .sending)
+        XCTAssertEqual(fullyChangedMessage.command, "new-command")
+        XCTAssertEqual(fullyChangedMessage.arguments, "new-arguments")
+        XCTAssertEqual(fullyChangedMessage.extraData["new"]?.stringValue, "data")
+        XCTAssertEqual(fullyChangedMessage.allAttachments.first?.id.index, 99)
+        XCTAssertEqual(fullyChangedMessage.translations?[.spanish], "Texto nuevo")
+        XCTAssertEqual(fullyChangedMessage.originalLanguage, .german)
+
+        // Verify key identifiers remain unchanged
+        XCTAssertEqual(fullyChangedMessage.id, originalMessage.id)
+        XCTAssertEqual(fullyChangedMessage.cid, originalMessage.cid)
+        XCTAssertEqual(fullyChangedMessage.author, originalMessage.author)
+    }
 }
diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
index ec9f419ad11..99f62668d79 100644
--- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
@@ -700,6 +700,158 @@ final class MessageRepositoryTests: XCTestCase {
         XCTAssertEqual(reactionState, .deletingFailed)
         XCTAssertEqual(reactionScore, 10)
     }
+
+    // MARK: - Interceptor Tests
+
+    final class MockSendMessageInterceptor: SendMessageInterceptor {
+        var sendMessageCalled = false
+        var receivedMessage: ChatMessage?
+        var receivedOptions: SendMessageOptions?
+        var completionResult: Result<SendMessageResponse, Error> = .success(.init(message: .mock()))
+
+        func sendMessage(
+            _ message: ChatMessage,
+            options: SendMessageOptions,
+            completion: @escaping ((Result<SendMessageResponse, Error>) -> Void)
+        ) {
+            sendMessageCalled = true
+            receivedMessage = message
+            receivedOptions = options
+            completion(completionResult)
+        }
+        
+        func simulateSuccess(message: ChatMessage) {
+            completionResult = .success(.init(message: message))
+        }
+        
+        func simulateFailure(error: Error) {
+            completionResult = .failure(error)
+        }
+    }
+
+    func test_setInterceptor_storesInterceptor() {
+        // Given
+        let interceptor = MockSendMessageInterceptor()
+        
+        // When
+        repository.setInterceptor(interceptor)
+        
+        // Then
+        XCTAssertNotNil(repository.interceptor)
+    }
+    
+    func test_sendMessage_withInterceptor_usesInterceptor() throws {
+        // Given
+        let id = MessageId.unique
+        let interceptor = MockSendMessageInterceptor()
+        repository.setInterceptor(interceptor)
+        try createMessage(id: id, localState: .pendingSend)
+
+        // When
+        interceptor.simulateSuccess(message: .mock(id: id))
+        let exp = expectation(description: "Send Message completes")
+        repository.sendMessage(with: id) { _ in
+            exp.fulfill()
+        }
+        wait(for: [exp], timeout: defaultTimeout)
+
+        // Then
+        XCTAssertTrue(interceptor.sendMessageCalled)
+        XCTAssertEqual(interceptor.receivedMessage?.id, id)
+        XCTAssertFalse(interceptor.receivedOptions?.skipPush ?? true)
+        XCTAssertFalse(interceptor.receivedOptions?.skipEnrichUrl ?? true)
+        XCTAssertNil(apiClient.request_endpoint) // API client should not be called
+    }
+    
+    func test_sendMessage_withInterceptor_passesSkipOptions() throws {
+        // Given
+        let id = MessageId.unique
+        let interceptor = MockSendMessageInterceptor()
+        repository.setInterceptor(interceptor)
+        try createMessage(id: id, localState: .pendingSend, skipPush: true, skipEnrichUrl: true)
+        
+        // When
+        interceptor.simulateSuccess(message: .mock(id: id))
+        let exp = expectation(description: "Send Message completes")
+        repository.sendMessage(with: id) { _ in
+            exp.fulfill()
+        }
+        wait(for: [exp], timeout: defaultTimeout)
+
+        // Then
+        XCTAssertTrue(interceptor.sendMessageCalled)
+        XCTAssertTrue(interceptor.receivedOptions?.skipPush ?? false)
+        XCTAssertTrue(interceptor.receivedOptions?.skipEnrichUrl ?? false)
+    }
+    
+    func test_sendMessage_withInterceptor_whenLocalStatePendingSend_shouldMarkStateSending() throws {
+        // Given
+        let id = MessageId.unique
+        let interceptor = MockSendMessageInterceptor()
+        repository.setInterceptor(interceptor)
+        try createMessage(id: id, localState: .pendingSend)
+        
+        // When
+        interceptor.simulateSuccess(message: .mock(id: id, localState: .pendingSend))
+        let exp = expectation(description: "Send Message completes")
+        repository.sendMessage(with: id) { _ in
+            exp.fulfill()
+        }
+        wait(for: [exp], timeout: defaultTimeout)
+
+        // Then
+        var messageState: LocalMessageState?
+        try database.writeSynchronously { session in
+            messageState = session.message(id: id)?.localMessageState
+        }
+        XCTAssertEqual(messageState, .sending)
+    }
+    
+    func test_sendMessage_withInterceptor_whenLocalStateNil_shouldMarkStatePublished() throws {
+        // Given
+        let id = MessageId.unique
+        let interceptor = MockSendMessageInterceptor()
+        repository.setInterceptor(interceptor)
+        try createMessage(id: id, localState: .pendingSend)
+
+        // When
+        interceptor.simulateSuccess(message: .mock(id: id, localState: nil))
+        let exp = expectation(description: "Send Message completes")
+        repository.sendMessage(with: id) { _ in
+            exp.fulfill()
+        }
+        wait(for: [exp], timeout: defaultTimeout)
+
+        // Then
+        var messageState: LocalMessageState?
+        try database.writeSynchronously { session in
+            messageState = session.message(id: id)?.localMessageState
+        }
+        XCTAssertNil(messageState)
+    }
+
+    func test_sendMessage_withInterceptor_whenFailure_shouldMarkStateSendingFailed() throws {
+        // Given
+        let id = MessageId.unique
+        let interceptor = MockSendMessageInterceptor()
+        repository.setInterceptor(interceptor)
+        try createMessage(id: id, localState: .pendingSend)
+
+        // When
+        interceptor.simulateFailure(error: TestError())
+        let exp = expectation(description: "Send Message completes")
+        repository.sendMessage(with: id) { _ in
+            exp.fulfill()
+        }
+        wait(for: [exp], timeout: defaultTimeout)
+
+        // Then
+        var messageState: LocalMessageState?
+        try database.writeSynchronously { session in
+            messageState = session.message(id: id)?.localMessageState
+        }
+        XCTAssertEqual(messageState, .sendingFailed)
+    }
 }
 
 extension MessageRepositoryTests {


Reviews:
user: login: martinmitrevski
body: This is great, love how you made it as future proof as it can be! 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T05:52:03Z
user: login: laevandus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:24:05Z
user: login: laevandus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:30:58Z
user: login: nuno-vieira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T10:14:47Z
user: login: nuno-vieira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T10:23:47Z
user: login: nuno-vieira
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T10:23:56Z
user: login: laevandus
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:16:37Z

Review comments:
diff_hunk: @@ -49,43 +57,46 @@ class MessageRepository {
             let requestBody = dto.asRequestBody() as MessageRequestBody
             let skipPush: Bool = dto.skipPush
             let skipEnrichUrl: Bool = dto.skipEnrichUrl
+            var message: ChatMessage?
 
             // Change the message state to `.sending` and the proceed with the actual sending
-            self?.database.write({
-                let messageDTO = $0.message(id: messageId)
-                messageDTO?.localMessageState = .sending
-            }, completion: { error in
-                if let error = error {
-                    log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
-                    self?.markMessageAsFailedToSend(id: messageId) {
-                        completion(.failure(.failedToSendMessage(error)))
+            self?.database.write(
path: Sources/StreamChat/Repositories/MessageRepository.swift
user: login: laevandus
body: Alternative
There is also `write(converting:` which allows returning a type within the action closure and uses Result type in the completion handler.
created_at: 2025-05-21T06:24:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,6 +169,57 @@ class MessageRepository {
         })
     }
 
+    /// Handles the result when sending the message to the server.
+    private func handleSentMessage(
+        _ result: Result<MessagePayload.Boxed, Error>,
+        cid: ChannelId,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(payload):
+            saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
+                switch result {
+                case let .success(message):
+                    completion(.success(message))
+                case let .failure(error):
+                    completion(.failure(.failedToSendMessage(error)))
+                }
+            }
+        case let .failure(error):
+            handleSendingMessageError(
+                error,
+                messageId: messageId,
+                completion: completion
+            )
+        }
+    }
+
+    /// Handles the result when the message is intercepted.
+    private func handleInterceptedMessage(
+        _ result: Result<SendMessageResponse, Error>,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(response):
+            let message = response.message
+            database.write { session in
+                guard let messageDTO = session.message(id: message.id) else { return }
+                if message.localState == nil {
+                    messageDTO.markMessageAsSent()
+                }
path: Sources/StreamChat/Repositories/MessageRepository.swift
user: login: laevandus
body: Is this right that it is being compared to nil (since the state is set to `.sending` before)?
created_at: 2025-05-21T06:30:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,6 +169,57 @@ class MessageRepository {
         })
     }
 
+    /// Handles the result when sending the message to the server.
+    private func handleSentMessage(
+        _ result: Result<MessagePayload.Boxed, Error>,
+        cid: ChannelId,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(payload):
+            saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
+                switch result {
+                case let .success(message):
+                    completion(.success(message))
+                case let .failure(error):
+                    completion(.failure(.failedToSendMessage(error)))
+                }
+            }
+        case let .failure(error):
+            handleSendingMessageError(
+                error,
+                messageId: messageId,
+                completion: completion
+            )
+        }
+    }
+
+    /// Handles the result when the message is intercepted.
+    private func handleInterceptedMessage(
+        _ result: Result<SendMessageResponse, Error>,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(response):
+            let message = response.message
+            database.write { session in
+                guard let messageDTO = session.message(id: message.id) else { return }
+                if message.localState == nil {
+                    messageDTO.markMessageAsSent()
+                }
path: Sources/StreamChat/Repositories/MessageRepository.swift
user: login: nuno-vieira
body: Yes, maybe I can add a comment here, the goal is that if a customer returns `localState == nil` it will mark it has Sent. Otherwise, it will wait for the `message.new` event to mark it as read. For most customers they will not change this. But in case a customer wants to treat it has published after the sendMessage response, they can do it
created_at: 2025-05-21T10:14:46Z
author_association: MEMBER
diff_hunk: @@ -49,43 +57,46 @@ class MessageRepository {
             let requestBody = dto.asRequestBody() as MessageRequestBody
             let skipPush: Bool = dto.skipPush
             let skipEnrichUrl: Bool = dto.skipEnrichUrl
+            var message: ChatMessage?
 
             // Change the message state to `.sending` and the proceed with the actual sending
-            self?.database.write({
-                let messageDTO = $0.message(id: messageId)
-                messageDTO?.localMessageState = .sending
-            }, completion: { error in
-                if let error = error {
-                    log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
-                    self?.markMessageAsFailedToSend(id: messageId) {
-                        completion(.failure(.failedToSendMessage(error)))
+            self?.database.write(
path: Sources/StreamChat/Repositories/MessageRepository.swift
user: login: nuno-vieira
body: Updated 👍 
created_at: 2025-05-21T10:23:47Z
author_association: MEMBER
diff_hunk: @@ -158,6 +169,57 @@ class MessageRepository {
         })
     }
 
+    /// Handles the result when sending the message to the server.
+    private func handleSentMessage(
+        _ result: Result<MessagePayload.Boxed, Error>,
+        cid: ChannelId,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(payload):
+            saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
+                switch result {
+                case let .success(message):
+                    completion(.success(message))
+                case let .failure(error):
+                    completion(.failure(.failedToSendMessage(error)))
+                }
+            }
+        case let .failure(error):
+            handleSendingMessageError(
+                error,
+                messageId: messageId,
+                completion: completion
+            )
+        }
+    }
+
+    /// Handles the result when the message is intercepted.
+    private func handleInterceptedMessage(
+        _ result: Result<SendMessageResponse, Error>,
+        messageId: MessageId,
+        completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
+    ) {
+        switch result {
+        case let .success(response):
+            let message = response.message
+            database.write { session in
+                guard let messageDTO = session.message(id: message.id) else { return }
+                if message.localState == nil {
+                    messageDTO.markMessageAsSent()
+                }
path: Sources/StreamChat/Repositories/MessageRepository.swift
user: login: nuno-vieira
body: Added the comment 👍 
created_at: 2025-05-21T10:23:56Z
author_association: MEMBER

PR comments:
user: login: Stream-SDK-Bot
created_at: 2025-05-19T15:40:33Z
body: ## SDK Size
| `title` | `develop` | `branch` | `diff` | `status` |
| - | - | - | - | - |
|StreamChat|7.2 MB|7.2 MB|+1 KB|🟢|
|StreamChatUI|4.72 MB|4.72 MB|0 KB|🟢|

author_association: COLLABORATOR
user: login: Stream-SDK-Bot
created_at: 2025-05-20T15:11:18Z
body: ## SDK Performance
| `target` | `metric` | `benchmark` | `branch` | `performance` | `status` |
| - | - | - | - | - | - |
| MessageList | Hitches total duration | 10 ms | 3.34 ms | 66.6% 🔼 | 🟢 |
|  | Duration | 2.6 s | 2.55 s | 1.92% 🔼 | 🟢 |
|  | Hitch time ratio | 4 ms per s | 1.31 ms per s | 67.25% 🔼 | 🟢 |
|  | Frame rate | 75 fps | 78.48 fps | 4.64% 🔼 | 🟢 |
|  | Number of hitches | 1  | 0.4  | 60.0% 🔼 | 🟢 |

author_association: COLLABORATOR
user: login: Stream-SDK-Bot
created_at: 2025-05-20T15:11:41Z
body: ## SDK Size
| `title` | `develop` | `branch` | `diff` | `status` |
| - | - | - | - | - |
|StreamChat|7.2 MB|7.2 MB|+1 KB|🟢|
|StreamChatUI|4.72 MB|4.72 MB|0 KB|🟢|

author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-20T18:07:25Z
body: <!--
  0 Errors
  1 Warning: Big PR
  0 Messages
  0 Markdowns
-->
<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true" data-kind="Warning">
          1 Warning
      </th>
     </tr>
  </thead>
  <tbody>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">Big PR</td>
    </tr>
  </tbody>
</table>

<p align="right" data-meta="generated_by_danger">
  Generated by :no_entry_sign: <a href="https://danger.systems/">Danger</a>
</p>

author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-21T10:49:25Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-swift&pullRequest=3671) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&pullRequest=3671&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&pullRequest=3671&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=GetStream_stream-chat-swift&pullRequest=3671&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [96.9% Coverage on New Code](https://sonarcloud.io/component_measures?id=GetStream_stream-chat-swift&pullRequest=3671&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=GetStream_stream-chat-swift&pullRequest=3671&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-swift&pullRequest=3671)


author_association: NONE