Skip to content

Commit 14b91d7

Browse files
committed
WIP
1 parent 29d6225 commit 14b91d7

File tree

8 files changed

+170
-69
lines changed

8 files changed

+170
-69
lines changed

DemoApp/Shared/StreamChatWrapper.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ final class StreamChatWrapper {
3737
config.shouldShowShadowedMessages = true
3838
config.applicationGroupIdentifier = applicationGroupIdentifier
3939
config.urlSessionConfiguration.httpAdditionalHeaders = ["Custom": "Example"]
40+
config.sendMessageInterceptor = CustomSendMessageInterceptor()
4041
// Uncomment this to test model transformers
4142
// config.modelsTransformer = CustomStreamModelsTransformer()
4243
configureUI()
@@ -221,3 +222,19 @@ class CustomStreamModelsTransformer: StreamModelsTransformer {
221222
)
222223
}
223224
}
225+
226+
class CustomSendMessageInterceptor: SendMessageInterceptor {
227+
func sendMessage(
228+
_ message: ChatMessage,
229+
options: SendMessageOptions,
230+
completion: @escaping (Result<ChatMessage, Error>) -> Void
231+
) {
232+
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
233+
completion(
234+
.success(
235+
message
236+
)
237+
)
238+
}
239+
}
240+
}

Sources/StreamChat/ChatClient+Environment.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,10 @@ extension ChatClient {
155155

156156
var messageRepositoryBuilder: (
157157
_ database: DatabaseContainer,
158-
_ apiClient: APIClient
158+
_ apiClient: APIClient,
159+
_ interceptor: SendMessageInterceptor?
159160
) -> MessageRepository = {
160-
MessageRepository(database: $0, apiClient: $1)
161+
MessageRepository(database: $0, apiClient: $1, interceptor: $2)
161162
}
162163

163164
var offlineRequestsRepositoryBuilder: (

Sources/StreamChat/ChatClient.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ public class ChatClient {
155155
)
156156
let messageRepository = environment.messageRepositoryBuilder(
157157
databaseContainer,
158-
apiClient
158+
apiClient,
159+
config.sendMessageInterceptor
159160
)
160161
let offlineRequestsRepository = environment.offlineRequestsRepositoryBuilder(
161162
messageRepository,

Sources/StreamChat/Config/ChatClientConfig.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ public struct ChatClientConfig {
6767
/// An object that provides a way to transform Stream Chat models.
6868
public var modelsTransformer: StreamModelsTransformer?
6969

70+
/// An object that provides a way to intercept messages before sending them to the server.
71+
public var sendMessageInterceptor: SendMessageInterceptor?
72+
7073
/// Advanced settings for the local caching and model serialization.
7174
public var localCaching = LocalCaching()
7275

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
public struct SendMessageOptions {
8+
let skipPush: Bool
9+
let skipEnrichUrl: Bool
10+
}
11+
12+
public protocol SendMessageInterceptor {
13+
func sendMessage(
14+
_ message: ChatMessage,
15+
options: SendMessageOptions,
16+
completion: @escaping ((Result<ChatMessage, Error>) -> Void)
17+
)
18+
}
19+
20+
class CustomSendMessageInterceptor: SendMessageInterceptor {
21+
func sendMessage(
22+
_ message: ChatMessage,
23+
options: SendMessageOptions,
24+
completion: @escaping ((Result<ChatMessage, Error>) -> Void)
25+
) {
26+
completion(.success(message))
27+
}
28+
}

Sources/StreamChat/Repositories/MessageRepository.swift

Lines changed: 110 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ enum MessageRepositoryError: LocalizedError {
1515
class MessageRepository {
1616
let database: DatabaseContainer
1717
let apiClient: APIClient
18+
var interceptor: SendMessageInterceptor?
1819

19-
init(database: DatabaseContainer, apiClient: APIClient) {
20+
init(
21+
database: DatabaseContainer,
22+
apiClient: APIClient,
23+
interceptor: SendMessageInterceptor?
24+
) {
2025
self.database = database
2126
self.apiClient = apiClient
27+
self.interceptor = interceptor
2228
}
2329

2430
func sendMessage(
@@ -49,84 +55,82 @@ class MessageRepository {
4955
let requestBody = dto.asRequestBody() as MessageRequestBody
5056
let skipPush: Bool = dto.skipPush
5157
let skipEnrichUrl: Bool = dto.skipEnrichUrl
58+
var message: ChatMessage?
5259

5360
// Change the message state to `.sending` and the proceed with the actual sending
54-
self?.database.write({
55-
let messageDTO = $0.message(id: messageId)
56-
messageDTO?.localMessageState = .sending
57-
}, completion: { error in
58-
if let error = error {
59-
log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
60-
self?.markMessageAsFailedToSend(id: messageId) {
61-
completion(.failure(.failedToSendMessage(error)))
61+
self?.database.write(
62+
{
63+
let messageDTO = $0.message(id: messageId)
64+
messageDTO?.localMessageState = .sending
65+
message = try messageDTO?.asModel()
66+
},
67+
completion: { error in
68+
if let error = error {
69+
log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)")
70+
self?.markMessageAsFailedToSend(id: messageId) {
71+
completion(.failure(.failedToSendMessage(error)))
72+
}
73+
return
6274
}
63-
return
64-
}
65-
66-
let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
67-
cid: cid,
68-
messagePayload: requestBody,
69-
skipPush: skipPush,
70-
skipEnrichUrl: skipEnrichUrl
71-
)
72-
self?.apiClient.request(endpoint: endpoint) {
73-
switch $0 {
74-
case let .success(payload):
75-
self?.saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in
76-
switch result {
75+
76+
if let interceptor = self?.interceptor, let message {
77+
let options = SendMessageOptions(
78+
skipPush: skipPush,
79+
skipEnrichUrl: skipEnrichUrl
80+
)
81+
interceptor.sendMessage(message, options: options) {
82+
switch $0 {
7783
case let .success(message):
7884
completion(.success(message))
7985
case let .failure(error):
80-
completion(.failure(.failedToSendMessage(error)))
86+
self?.handleSendingMessageError(
87+
error,
88+
messageId: messageId,
89+
completion: completion
90+
)
8191
}
8292
}
83-
84-
case let .failure(error):
85-
self?.handleSendingMessageError(error, messageId: messageId, completion: completion)
93+
return
94+
}
95+
96+
let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
97+
cid: cid,
98+
messagePayload: requestBody,
99+
skipPush: skipPush,
100+
skipEnrichUrl: skipEnrichUrl
101+
)
102+
self?.apiClient.request(endpoint: endpoint) {
103+
self?.handleNewMessage(
104+
messageId: messageId,
105+
cid: cid,
106+
result: $0.map(\.message),
107+
completion: completion
108+
)
86109
}
87110
}
88-
})
111+
)
89112
}
90113
}
91-
92-
/// Marks the message's local status to failed and adds it to the offline retry which sends the message when connection comes back.
93-
func scheduleOfflineRetry(for messageId: MessageId, completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void) {
94-
var dataEndpoint: DataEndpoint!
95-
var messageModel: ChatMessage!
96-
database.write { session in
97-
guard let dto = session.message(id: messageId) else {
98-
throw MessageRepositoryError.messageDoesNotExist
99-
}
100-
guard let channelDTO = dto.channel, let cid = try? ChannelId(cid: channelDTO.cid) else {
101-
throw MessageRepositoryError.messageDoesNotHaveValidChannel
102-
}
103-
104-
// Send the message to offline handling
105-
let requestBody = dto.asRequestBody() as MessageRequestBody
106-
let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
107-
cid: cid,
108-
messagePayload: requestBody,
109-
skipPush: dto.skipPush,
110-
skipEnrichUrl: dto.skipEnrichUrl
111-
)
112-
dataEndpoint = endpoint.withDataResponse
113-
114-
// Mark it as failed
115-
dto.localMessageState = .sendingFailed
116-
messageModel = try dto.asModel()
117-
} completion: { [weak self] writeError in
118-
if let writeError {
119-
switch writeError {
120-
case let repositoryError as MessageRepositoryError:
121-
completion(.failure(repositoryError))
122-
default:
123-
completion(.failure(.failedToSendMessage(writeError)))
114+
115+
func handleNewMessage(
116+
messageId: MessageId,
117+
cid: ChannelId,
118+
result: Result<MessagePayload, Error>,
119+
completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void
120+
) {
121+
switch result {
122+
case let .success(payload):
123+
saveSuccessfullySentMessage(cid: cid, message: payload) { result in
124+
switch result {
125+
case let .success(message):
126+
completion(.success(message))
127+
case let .failure(error):
128+
completion(.failure(.failedToSendMessage(error)))
124129
}
125-
return
126130
}
127-
// Offline repository will send it when connection comes back on, until then we show the message as failed
128-
self?.apiClient.queueOfflineRequest?(dataEndpoint.withDataResponse)
129-
completion(.success(messageModel))
131+
132+
case let .failure(error):
133+
handleSendingMessageError(error, messageId: messageId, completion: completion)
130134
}
131135
}
132136

@@ -345,6 +349,47 @@ class MessageRepository {
345349
completion?()
346350
}
347351
}
352+
353+
/// Marks the message's local status to failed and adds it to the offline retry which sends the message when connection comes back.
354+
func scheduleOfflineRetry(for messageId: MessageId, completion: @escaping (Result<ChatMessage, MessageRepositoryError>) -> Void) {
355+
var dataEndpoint: DataEndpoint!
356+
var messageModel: ChatMessage!
357+
database.write { session in
358+
guard let dto = session.message(id: messageId) else {
359+
throw MessageRepositoryError.messageDoesNotExist
360+
}
361+
guard let channelDTO = dto.channel, let cid = try? ChannelId(cid: channelDTO.cid) else {
362+
throw MessageRepositoryError.messageDoesNotHaveValidChannel
363+
}
364+
365+
// Send the message to offline handling
366+
let requestBody = dto.asRequestBody() as MessageRequestBody
367+
let endpoint: Endpoint<MessagePayload.Boxed> = .sendMessage(
368+
cid: cid,
369+
messagePayload: requestBody,
370+
skipPush: dto.skipPush,
371+
skipEnrichUrl: dto.skipEnrichUrl
372+
)
373+
dataEndpoint = endpoint.withDataResponse
374+
375+
// Mark it as failed
376+
dto.localMessageState = .sendingFailed
377+
messageModel = try dto.asModel()
378+
} completion: { [weak self] writeError in
379+
if let writeError {
380+
switch writeError {
381+
case let repositoryError as MessageRepositoryError:
382+
completion(.failure(repositoryError))
383+
default:
384+
completion(.failure(.failedToSendMessage(writeError)))
385+
}
386+
return
387+
}
388+
// Offline repository will send it when connection comes back on, until then we show the message as failed
389+
self?.apiClient.queueOfflineRequest?(dataEndpoint.withDataResponse)
390+
completion(.success(messageModel))
391+
}
392+
}
348393
}
349394

350395
extension MessageRepository {

StreamChat.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,8 @@
16651665
AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; };
16661666
AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; };
16671667
AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; };
1668+
AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; };
1669+
AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; };
16681670
ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; };
16691671
ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; };
16701672
ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */; };
@@ -4381,6 +4383,7 @@
43814383
AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = "<group>"; };
43824384
AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = "<group>"; };
43834385
AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
4386+
AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageInterceptor.swift; sourceTree = "<group>"; };
43844387
ADA03A212D64EFE900DFE048 /* DraftMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage.swift; sourceTree = "<group>"; };
43854388
ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage_Mock.swift; sourceTree = "<group>"; };
43864389
ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = "<group>"; };
@@ -5715,6 +5718,7 @@
57155718
79FC85E624ACCBC500A665ED /* Token.swift */,
57165719
C1B49B3C2822A7AD00F4E89E /* StreamRuntimeCheck.swift */,
57175720
AD81FEEC2D3977AC00765FD4 /* StreamModelsTransformer.swift */,
5721+
AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */,
57185722
);
57195723
path = Config;
57205724
sourceTree = "<group>";
@@ -11537,6 +11541,7 @@
1153711541
AD52A2192804850700D0157E /* ChannelConfigDTO.swift in Sources */,
1153811542
797A756824814F0D003CF16D /* Bundle+Extensions.swift in Sources */,
1153911543
7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */,
11544+
AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */,
1154011545
DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */,
1154111546
64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */,
1154211547
C1FFD9F927ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */,
@@ -12588,6 +12593,7 @@
1258812593
C121E8C6274544B100023E4C /* ChannelWatcherListQuery.swift in Sources */,
1258912594
4F05C0722C8832C40085B4B7 /* URLRequest+cURL.swift in Sources */,
1259012595
AD37D7CB2BC98A5300800D8C /* ThreadReadDTO.swift in Sources */,
12596+
AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */,
1259112597
C121E8C7274544B100023E4C /* ChannelListQuery.swift in Sources */,
1259212598
841BAA0B2BCE9B57000C73E4 /* CreatePollOptionRequestBody.swift in Sources */,
1259312599
C121E8C8274544B100023E4C /* UserListQuery.swift in Sources */,

Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ final class MessageRepositoryTests: XCTestCase {
1616
let client = ChatClient.mock
1717
database = client.mockDatabaseContainer
1818
apiClient = client.mockAPIClient
19-
repository = MessageRepository(database: database, apiClient: apiClient)
19+
repository = MessageRepository(database: database, apiClient: apiClient, interceptor: nil)
2020
cid = .unique
2121
}
2222

0 commit comments

Comments
 (0)