Skip to content

Add SendMessageInterceptor to intercept send message request #3671

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/Config/ChatClientConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
34 changes: 34 additions & 0 deletions Sources/StreamChat/Config/SendMessageInterceptor.swift
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 122 additions & 1 deletion Sources/StreamChat/Models/ChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -294,7 +415,7 @@ public struct ChatMessage {
threadParticipants: threadParticipants,
attachments: attachments ?? [],
latestReplies: latestReplies,
localState: localState,
localState: state,
isFlaggedByCurrentUser: isFlaggedByCurrentUser,
latestReactions: latestReactions,
currentUserReactions: currentUserReactions,
Expand Down
123 changes: 93 additions & 30 deletions Sources/StreamChat/Repositories/MessageRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import CoreData
import Foundation

enum MessageRepositoryError: LocalizedError {
enum MessageRepositoryError: Error {
case messageDoesNotExist
case messageNotPendingSend
case messageDoesNotHaveValidChannel
Expand All @@ -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
Expand Down Expand Up @@ -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)))
}
}
})
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading