From e275c6b28b3d08d3ffef642ae412cc32c98a5fd9 Mon Sep 17 00:00:00 2001
From: Stream Bot
Date: Tue, 18 Nov 2025 15:48:37 +0000
Subject: [PATCH 1/9] Update release version to snapshot
---
Sources/StreamChat/Generated/SystemEnvironment+Version.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
index 5d2a7a0017..3e66ea65c4 100644
--- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
+++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
@@ -7,5 +7,5 @@ import Foundation
extension SystemEnvironment {
/// A Stream Chat version.
- public static let version: String = "4.93.0"
+ public static let version: String = "4.94.0-SNAPSHOT"
}
From 571a48871be7c2abd80adc9b762ed3e1d43a0319 Mon Sep 17 00:00:00 2001
From: Nuno Vieira
Date: Tue, 18 Nov 2025 23:28:17 +0000
Subject: [PATCH 2/9] Fix marking channel read when scrolling to bottom without
unread count (#3881)
* Fix marking channel read when scrolling to bottom without unread count
* Update CHANGELOG.md
---
CHANGELOG.md | 4 +++-
.../ChatChannel/ChatChannelVC.swift | 7 ++++++-
.../ChatChannel/ChatChannelVC_Tests.swift | 20 ++++++++++++++++++-
3 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84d84be06b..fd5cd610d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming
-### 🔄 Changed
+## StreamChatUI
+### 🐞 Fixed
+- Fix marking channel read when scrolling to the bottom without unread counts [#3881](https://github.com/GetStream/stream-chat-swift/pull/3881)
# [4.93.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.93.0)
_November 18, 2025_
diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
index 715f1fe165..63a5173569 100644
--- a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
+++ b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
@@ -89,7 +89,12 @@ open class ChatChannelVC: _ViewController,
return isLastMessageFullyVisible && isFirstPageLoaded
}
- return isLastMessageVisibleOrSeen && hasSeenFirstUnreadMessage && isFirstPageLoaded && !hasMarkedMessageAsUnread
+ let unreadMessageCount = channelController.channel?.unreadCount.messages ?? 0
+ return isLastMessageVisibleOrSeen
+ && hasSeenFirstUnreadMessage
+ && isFirstPageLoaded
+ && !hasMarkedMessageAsUnread
+ && unreadMessageCount > 0
}
private var isLastMessageVisibleOrSeen: Bool {
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
index 183950bafe..98b15d1084 100644
--- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
+++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
@@ -904,11 +904,12 @@ final class ChatChannelVC_Tests: XCTestCase {
XCTAssertTrue(vc.shouldMarkChannelRead)
}
- func test_shouldMarkChannelRead_jumpToUnreadEnabled_viewIsVisible_remoteDataFetched_lastMessageVisible_hasLoadedAllNextMessages_hasNotMarkedMessageAsUnread_shouldReturnTrue() {
+ func test_shouldMarkChannelRead_jumpToUnreadEnabled_viewIsVisible_remoteDataFetched_lastMessageVisible_hasLoadedAllNextMessages_hasNotMarkedMessageAsUnread_withUnreads_shouldReturnTrue() {
let mockedListView = makeMockMessageListView()
vc.mockIsViewVisible(true)
vc.components.isJumpToUnreadEnabled = true
channelControllerMock.state_mock = .remoteDataFetched
+ channelControllerMock.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
mockedListView.mockIsLastCellFullyVisible = true
channelControllerMock.hasLoadedAllNextMessages_mock = true
channelControllerMock.markedAsUnread_mock = false
@@ -919,6 +920,23 @@ final class ChatChannelVC_Tests: XCTestCase {
XCTAssertTrue(vc.shouldMarkChannelRead)
}
+
+ func test_shouldMarkChannelRead_jumpToUnreadEnabled_viewIsVisible_remoteDataFetched_lastMessageVisible_hasLoadedAllNextMessages_hasNotMarkedMessageAsUnread_withoutUnreads_shouldReturnFalse() {
+ let mockedListView = makeMockMessageListView()
+ vc.mockIsViewVisible(true)
+ vc.components.isJumpToUnreadEnabled = true
+ channelControllerMock.state_mock = .remoteDataFetched
+ channelControllerMock.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 0, mentions: 0))
+ mockedListView.mockIsLastCellFullyVisible = true
+ channelControllerMock.hasLoadedAllNextMessages_mock = true
+ channelControllerMock.markedAsUnread_mock = false
+
+ // Simulate display to update hasSeenLastMessage && hasSeenFirstUnreadMessage
+ vc.chatMessageListVC(ChatMessageListVC_Mock(), willDisplayMessageAt: IndexPath(item: 0, section: 0))
+ vc.chatMessageListVC(ChatMessageListVC_Mock(), scrollViewDidScroll: UIScrollView())
+
+ XCTAssertFalse(vc.shouldMarkChannelRead)
+ }
func test_shouldMarkChannelRead_jumpToUnreadEnabled_whenNotSeenLastMessage_whenNotSeenFirstUnreadMessage_shouldReturnFalse() {
let mockedListView = makeMockMessageListView()
From 555c805f7361317c214d94dfe4f2bebb23871bac Mon Sep 17 00:00:00 2001
From: Toomas Vahter
Date: Tue, 25 Nov 2025 14:16:53 +0200
Subject: [PATCH 3/9] Fix rare crash in WebSocketClient.connectEndpoint (#3882)
---
CHANGELOG.md | 4 ++++
Sources/StreamChat/WebSocketClient/WebSocketClient.swift | 2 +-
.../WebSocketClient/WebSocketClient_Tests.swift | 6 ++++++
3 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd5cd610d5..ddb5d2618a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming
+## StreamChat
+### 🐞 Fixed
+- Fix rare crash in WebSocketClient.connectEndpoint [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
+
## StreamChatUI
### 🐞 Fixed
- Fix marking channel read when scrolling to the bottom without unread counts [#3881](https://github.com/GetStream/stream-chat-swift/pull/3881)
diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
index b48e1410e7..9c162d311d 100644
--- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
+++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
@@ -42,7 +42,7 @@ class WebSocketClient {
///
/// Changing this value doesn't automatically update the existing connection. You need to manually call `disconnect`
/// and `connect` to make a new connection to the updated endpoint.
- var connectEndpoint: Endpoint?
+ @Atomic var connectEndpoint: Endpoint?
/// The decoder used to decode incoming events
private let eventDecoder: AnyEventDecoder
diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
index 2443a29cf7..bfe6d78bc3 100644
--- a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
+++ b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
@@ -372,6 +372,12 @@ final class WebSocketClient_Tests: XCTestCase {
Assert.willBeEqual(self.engine!.connect_calledCount, 1)
}
}
+
+ func test_changingConnectEndpointConcurrently() {
+ DispatchQueue.concurrentPerform(iterations: 100, execute: { index in
+ self.webSocketClient.connectEndpoint = .webSocketConnect(userInfo: UserInfo(id: "user_\(index)"))
+ })
+ }
// MARK: - Event handling tests
From 3f1c281d1ef2e61e0972817108064b4e613e491d Mon Sep 17 00:00:00 2001
From: Nuno Vieira
Date: Wed, 26 Nov 2025 11:34:31 +0000
Subject: [PATCH 4/9] Add `ChatClient.deleteAttachment()` +
`ChatClient.uploadAttachment()` (#3883)
* Add `ChatClient.deleteAttachment(remoteUrl:attachmentType:)`
* Add delete action of the user avatar in the demo app
* Update the demo app user profile avatar update with the simpler interface
* Add test coverage
* Add documentation to the new interfaces
* Remove attachmentType from delete endpoint
* Fix hard coded attachment type
* Add tests for the delete endpoint
* Update CHANGELOG.md
* Add `heic`, `heif` and `svg` image types to supported file types
* Add support for heic, heif, and svg image formats
Added support for additional image file types in the ChatClient.
---
CHANGELOG.md | 5 +
.../UserProfileViewController.swift | 103 +++++++++++-------
Sources/StreamChat/APIClient/APIClient.swift | 7 +-
.../APIClient/CDNClient/CDNClient.swift | 43 +++++++-
.../Endpoints/AttachmentEndpoints.swift | 10 ++
.../StreamChat/ChatClient+Environment.swift | 3 +-
Sources/StreamChat/ChatClient.swift | 52 +++++++++
Sources/StreamChat/ChatClientFactory.swift | 14 ++-
.../Models/Attachments/AttachmentTypes.swift | 18 ++-
.../SpyPattern/Spy/APIClient_Spy.swift | 9 +-
.../SpyPattern/Spy/CDNClient_Spy.swift | 14 +++
.../TestData/CustomCDNClient.swift | 11 +-
.../APIClient/APIClient_Tests.swift | 6 +-
.../ChatRemoteNotificationHandler_Tests.swift | 2 +-
.../Endpoints/AttachmentEndpoints_Tests.swift | 54 +++++++++
.../APIClient/StreamCDNClient_Tests.swift | 98 +++++++++++++++++
Tests/StreamChatTests/ChatClient_Tests.swift | 3 +-
17 files changed, 395 insertions(+), 57 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddb5d2618a..2df65e9d60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming
## StreamChat
+### ✅ Added
+- Add `ChatClient.uploadAttachment(localUrl:progress:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
+- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
+- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
+- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
### 🐞 Fixed
- Fix rare crash in WebSocketClient.connectEndpoint [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
diff --git a/DemoApp/Screens/UserProfile/UserProfileViewController.swift b/DemoApp/Screens/UserProfile/UserProfileViewController.swift
index c8d1e8d764..415083d271 100644
--- a/DemoApp/Screens/UserProfile/UserProfileViewController.swift
+++ b/DemoApp/Screens/UserProfile/UserProfileViewController.swift
@@ -3,6 +3,7 @@
//
import StreamChat
+import StreamChatUI
import SwiftUI
import UIKit
@@ -152,13 +153,12 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
}
private func updateUserData() {
- guard let imageURL = currentUserController.currentUser?.imageURL else { return }
- DispatchQueue.global().async { [weak self] in
- guard let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) else { return }
- DispatchQueue.main.async {
- self?.imageView.image = image
- }
- }
+ Components.default
+ .imageLoader
+ .loadImage(
+ into: imageView,
+ from: currentUserController.currentUser?.imageURL
+ )
if let typingIndicatorsEnabled = currentUserController.currentUser?.privacySettings.typingIndicators?.enabled {
UserConfig.shared.typingIndicatorsEnabled = typingIndicatorsEnabled
@@ -268,6 +268,12 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
self?.presentImagePicker(sourceType: .photoLibrary)
})
+ if currentUserController.currentUser?.imageURL != nil {
+ alertController.addAction(UIAlertAction(title: "Delete Avatar", style: .destructive) { [weak self] _ in
+ self?.deleteAvatar()
+ })
+ }
+
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = alertController.popoverPresentationController {
@@ -312,45 +318,20 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
}
private func uploadImageAndUpdateProfile(_ image: UIImage, completion: @escaping (Error?) -> Void) {
- guard let imageData = image.pngData() else {
- completion(NSError(domain: "UserProfile", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to PNG data"]))
+ guard let imageLocalUrl = image.tempFileURL() else {
+ completion(ClientError("Failed to get local url."))
return
}
-
- // Create temporary file
- let imageURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar_\(UUID().uuidString).png")
-
- do {
- try imageData.write(to: imageURL)
- } catch {
- completion(error)
- return
- }
-
- let uploadingState = AttachmentUploadingState(
- localFileURL: imageURL,
- state: .pendingUpload,
- file: .init(type: .png, size: Int64(imageData.count), mimeType: "image/png")
- )
-
- let attachment = StreamAttachment(
- type: .image,
- payload: imageData,
- downloadingState: nil,
- uploadingState: uploadingState
- )
-
+
// Upload the image
- currentUserController.client.upload(attachment, progress: { progress in
+ currentUserController.client.uploadAttachment(localUrl: imageLocalUrl, progress: { progress in
print("Upload progress: \(progress)")
}, completion: { [weak self] result in
- // Clean up temporary file
- try? FileManager.default.removeItem(at: imageURL)
-
switch result {
case .success(let file):
// Update user profile with new image URL
self?.currentUserController.updateUserData(imageURL: file.fileURL) { error in
+ self?.updateUserData()
completion(error)
}
case .failure(let error):
@@ -359,6 +340,40 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
})
}
+ private func deleteAvatar() {
+ guard let imageURL = currentUserController.currentUser?.imageURL else {
+ return
+ }
+
+ loadingSpinner.startAnimating()
+
+ // Delete the attachment from CDN
+ currentUserController.client.deleteAttachment(remoteUrl: imageURL) { [weak self] error in
+ if let error = error {
+ self?.loadingSpinner.stopAnimating()
+ self?.showError(error)
+ } else {
+ // Only update user data if deletion was successful
+ self?.currentUserController.updateUserData(unsetProperties: ["image"]) { updateError in
+ self?.loadingSpinner.stopAnimating()
+
+ if let updateError = updateError {
+ self?.showError(updateError)
+ } else {
+ self?.updateUserData()
+ let alert = UIAlertController(
+ title: "Success",
+ message: "Avatar deleted successfully!",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ self?.present(alert, animated: true)
+ }
+ }
+ }
+ }
+ }
+
private func showError(_ error: Error) {
let alert = UIAlertController(
title: "Upload Failed",
@@ -379,3 +394,17 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
present(alert, animated: true)
}
}
+
+extension UIImage {
+ func tempFileURL() -> URL? {
+ guard let imageData = self.pngData() else { return nil }
+ let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
+ let imageURL = tempDir.appendingPathComponent("avatar_\(UUID().uuidString).png")
+ do {
+ try imageData.write(to: imageURL)
+ return imageURL
+ } catch {
+ return nil
+ }
+ }
+}
diff --git a/Sources/StreamChat/APIClient/APIClient.swift b/Sources/StreamChat/APIClient/APIClient.swift
index c63078301b..b3b48b865b 100644
--- a/Sources/StreamChat/APIClient/APIClient.swift
+++ b/Sources/StreamChat/APIClient/APIClient.swift
@@ -27,6 +27,9 @@ class APIClient {
/// The attachment uploader.
let attachmentUploader: AttachmentUploader
+ /// The CDN Client to store and delete attachments.
+ let cdnClient: CDNClient
+
/// Queue in charge of handling incoming requests
private let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
@@ -63,13 +66,15 @@ class APIClient {
requestEncoder: RequestEncoder,
requestDecoder: RequestDecoder,
attachmentDownloader: AttachmentDownloader,
- attachmentUploader: AttachmentUploader
+ attachmentUploader: AttachmentUploader,
+ cdnClient: CDNClient
) {
encoder = requestEncoder
decoder = requestDecoder
session = URLSession(configuration: sessionConfiguration)
self.attachmentDownloader = attachmentDownloader
self.attachmentUploader = attachmentUploader
+ self.cdnClient = cdnClient
}
/// Performs a network request and retries in case of network failures
diff --git a/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift b/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
index 3c84ccbf12..4852851545 100644
--- a/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
+++ b/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
@@ -51,6 +51,15 @@ public protocol CDNClient {
progress: ((Double) -> Void)?,
completion: @escaping (Result) -> Void
)
+
+ /// Deletes the attachment from the CDN, given the remote URL.
+ /// - Parameters:
+ /// - remoteUrl: The remote url of the attachment.
+ /// - completion: Returns an error in case the delete operation fails.
+ func deleteAttachment(
+ remoteUrl: URL,
+ completion: @escaping (Error?) -> Void
+ )
}
public extension CDNClient {
@@ -147,7 +156,39 @@ class StreamCDNClient: CDNClient {
completion: completion
)
}
-
+
+ func deleteAttachment(
+ remoteUrl: URL,
+ completion: @escaping (Error?) -> Void
+ ) {
+ let isImage = AttachmentFileType(ext: remoteUrl.pathExtension).isImage
+ let endpoint = Endpoint
+ .deleteAttachment(
+ url: remoteUrl,
+ type: isImage ? .image : .file
+ )
+
+ encoder.encodeRequest(for: endpoint) { [weak self] (requestResult) in
+ var urlRequest: URLRequest
+
+ do {
+ urlRequest = try requestResult.get()
+ } catch {
+ log.error(error, subsystems: .httpRequests)
+ completion(error)
+ return
+ }
+
+ guard let self = self else {
+ return
+ }
+
+ self.session.dataTask(with: urlRequest, completionHandler: { _, _, error in
+ completion(error)
+ }).resume()
+ }
+ }
+
private func uploadAttachment(
endpoint: Endpoint,
fileData: Data,
diff --git a/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift
index ff770298ac..4c8572d12d 100644
--- a/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift
@@ -25,6 +25,16 @@ extension Endpoint {
)
}
+ static func deleteAttachment(url: URL, type: AttachmentType) -> Endpoint {
+ .init(
+ path: .uploadAttachment(type == .image ? "image" : "file"),
+ method: .delete,
+ queryItems: nil,
+ requiresConnectionId: false,
+ body: ["url": url.absoluteString]
+ )
+ }
+
static func enrichUrl(url: URL)
-> Endpoint {
.init(
diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift
index d06828377f..5e531c39bb 100644
--- a/Sources/StreamChat/ChatClient+Environment.swift
+++ b/Sources/StreamChat/ChatClient+Environment.swift
@@ -12,7 +12,8 @@ extension ChatClient {
_ requestEncoder: RequestEncoder,
_ requestDecoder: RequestDecoder,
_ attachmentDownloader: AttachmentDownloader,
- _ attachmentUploader: AttachmentUploader
+ _ attachmentUploader: AttachmentUploader,
+ _ cdnClient: CDNClient
) -> APIClient = APIClient.init
var webSocketClientBuilder: ((
diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift
index e90b09a784..0188b34801 100644
--- a/Sources/StreamChat/ChatClient.swift
+++ b/Sources/StreamChat/ChatClient.swift
@@ -665,6 +665,58 @@ public class ChatClient {
)
}
+ /// Uploads an attachment to the specified CDN.
+ ///
+ /// - Parameters:
+ /// - localUrl: the local url of the file to be uploaded.
+ /// - progress: the progress of the upload.
+ /// - completion: called when the attachment is uploaded.
+ public func uploadAttachment(
+ localUrl: URL,
+ progress: ((Double) -> Void)?,
+ completion: @escaping (Result) -> Void
+ ) {
+ let uploadingState: AttachmentUploadingState
+
+ do {
+ uploadingState = AttachmentUploadingState(
+ localFileURL: localUrl,
+ state: .pendingUpload,
+ file: try .init(url: localUrl)
+ )
+ } catch {
+ completion(.failure(error))
+ return
+ }
+
+ let attachment = StreamAttachment(
+ type: uploadingState.file.type.isImage ? .image : .file,
+ payload: localUrl,
+ downloadingState: nil,
+ uploadingState: uploadingState
+ )
+
+ apiClient.attachmentUploader.uploadStandaloneAttachment(
+ attachment,
+ progress: progress,
+ completion: completion
+ )
+ }
+
+ /// Deletes the attachment from the CDN, given the remote URL.
+ /// - Parameters:
+ /// - remoteUrl: The remote url of the attachment.
+ /// - completion: Returns an error in case the delete operation fails.
+ public func deleteAttachment(
+ remoteUrl: URL,
+ completion: @escaping (Error?) -> Void
+ ) {
+ apiClient.cdnClient.deleteAttachment(
+ remoteUrl: remoteUrl,
+ completion: completion
+ )
+ }
+
// MARK: - Internal
func createBackgroundWorkers() {
diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift
index c76fe3959f..fb02d50c56 100644
--- a/Sources/StreamChat/ChatClientFactory.swift
+++ b/Sources/StreamChat/ChatClientFactory.swift
@@ -47,19 +47,21 @@ class ChatClientFactory {
) -> APIClient {
let attachmentDownloader = StreamAttachmentDownloader(sessionConfiguration: urlSessionConfiguration)
let decoder = environment.requestDecoderBuilder()
+ let cdnClient = config.customCDNClient ?? StreamCDNClient(
+ encoder: encoder,
+ decoder: decoder,
+ sessionConfiguration: urlSessionConfiguration
+ )
let attachmentUploader = config.customAttachmentUploader ?? StreamAttachmentUploader(
- cdnClient: config.customCDNClient ?? StreamCDNClient(
- encoder: encoder,
- decoder: decoder,
- sessionConfiguration: urlSessionConfiguration
- )
+ cdnClient: cdnClient
)
let apiClient = environment.apiClientBuilder(
urlSessionConfiguration,
encoder,
decoder,
attachmentDownloader,
- attachmentUploader
+ attachmentUploader,
+ cdnClient
)
return apiClient
}
diff --git a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
index d1941d4cda..96b93c85c8 100644
--- a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
+++ b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
@@ -223,7 +223,7 @@ public enum AttachmentFileType: String, Codable, Equatable, CaseIterable {
/// Video
case mov, avi, wmv, webm
/// Image
- case jpeg, png, gif, bmp, webp
+ case jpeg, png, gif, bmp, webp, heic, heif, svg
/// Unknown
case unknown
@@ -260,7 +260,12 @@ public enum AttachmentFileType: String, Codable, Equatable, CaseIterable {
"image/png": .png,
"image/gif": .gif,
"image/bmp": .bmp,
- "image/webp": .webp
+ "image/webp": .webp,
+ "image/heic": .heic,
+ "image/heic-sequence": .heic,
+ "image/heif": .heif,
+ "image/heif-sequence": .heif,
+ "image/svg+xml": .svg
]
/// Init an attachment file type by mime type.
@@ -312,6 +317,15 @@ public enum AttachmentFileType: String, Codable, Equatable, CaseIterable {
}
}
+ public var isImage: Bool {
+ switch self {
+ case .jpeg, .png, .webp, .gif, .bmp:
+ return true
+ default:
+ return false
+ }
+ }
+
public var isUnknown: Bool {
self == .unknown
}
diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/APIClient_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/APIClient_Spy.swift
index 5ee6e1389e..e5366eccf0 100644
--- a/TestTools/StreamChatTestTools/SpyPattern/Spy/APIClient_Spy.swift
+++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/APIClient_Spy.swift
@@ -85,7 +85,8 @@ final class APIClient_Spy: APIClient, Spy {
requestEncoder: RequestEncoder,
requestDecoder: RequestDecoder,
attachmentDownloader: AttachmentDownloader,
- attachmentUploader: AttachmentUploader
+ attachmentUploader: AttachmentUploader,
+ cdnClient: CDNClient
) {
init_sessionConfiguration = sessionConfiguration
init_requestEncoder = requestEncoder
@@ -101,7 +102,8 @@ final class APIClient_Spy: APIClient, Spy {
requestEncoder: requestEncoder,
requestDecoder: requestDecoder,
attachmentDownloader: attachmentDownloader,
- attachmentUploader: attachmentUploader
+ attachmentUploader: attachmentUploader,
+ cdnClient: CDNClient_Spy()
)
}
@@ -229,7 +231,8 @@ extension APIClient_Spy {
requestEncoder: DefaultRequestEncoder(baseURL: .unique(), apiKey: .init(.unique)),
requestDecoder: DefaultRequestDecoder(),
attachmentDownloader: AttachmentDownloader_Spy(),
- attachmentUploader: AttachmentUploader_Spy()
+ attachmentUploader: AttachmentUploader_Spy(),
+ cdnClient: CDNClient_Spy()
)
}
}
diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift
index 42ad7a75cb..e4f8167be3 100644
--- a/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift
+++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift
@@ -11,6 +11,9 @@ final class CDNClient_Spy: CDNClient, Spy {
static var maxAttachmentSize: Int64 { .max }
var uploadAttachmentProgress: Double?
var uploadAttachmentResult: Result?
+
+ var deleteAttachmentRemoteUrl: URL?
+ var deleteAttachmentResult: Error?
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
@@ -45,4 +48,15 @@ final class CDNClient_Spy: CDNClient, Spy {
}
}
}
+
+ func deleteAttachment(
+ remoteUrl: URL,
+ completion: @escaping (Error?) -> Void
+ ) {
+ record()
+ deleteAttachmentRemoteUrl = remoteUrl
+ if let result = deleteAttachmentResult {
+ completion(result)
+ }
+ }
}
diff --git a/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift b/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift
index ddf9d0d3a2..4c6466eada 100644
--- a/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift
+++ b/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift
@@ -3,6 +3,7 @@
//
import Foundation
+
@testable import StreamChat
public final class CustomCDNClient: CDNClient {
@@ -13,10 +14,16 @@ public final class CustomCDNClient: CDNClient {
progress: ((Double) -> Void)?,
completion: @escaping (Result) -> Void
) {}
-
+
public func uploadStandaloneAttachment(
_ attachment: StreamChat.StreamAttachment,
progress: ((Double) -> Void)?,
- completion: @escaping (Result) -> Void
+ completion:
+ @escaping (Result) -> Void
+ ) {}
+
+ public func deleteAttachment(
+ remoteUrl: URL,
+ completion: @escaping ((any Error)?) -> Void
) {}
}
diff --git a/Tests/StreamChatTests/APIClient/APIClient_Tests.swift b/Tests/StreamChatTests/APIClient/APIClient_Tests.swift
index 76851abec9..3d2ce54a62 100644
--- a/Tests/StreamChatTests/APIClient/APIClient_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/APIClient_Tests.swift
@@ -50,7 +50,8 @@ final class APIClient_Tests: XCTestCase {
requestEncoder: encoder,
requestDecoder: decoder,
attachmentDownloader: attachmentDownloader,
- attachmentUploader: attachmentUploader
+ attachmentUploader: attachmentUploader,
+ cdnClient: CDNClient_Spy()
)
apiClient.tokenRefresher = tokenRefresher
apiClient.queueOfflineRequest = queueOfflineRequest
@@ -714,7 +715,8 @@ extension APIClient_Tests {
requestEncoder: encoder,
requestDecoder: decoder,
attachmentDownloader: attachmentDownloader,
- attachmentUploader: attachmentUploader
+ attachmentUploader: attachmentUploader,
+ cdnClient: CDNClient_Spy()
)
apiClient.tokenRefresher = self.tokenRefresher
apiClient.queueOfflineRequest = self.queueOfflineRequest
diff --git a/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift b/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift
index f97b3bf803..fd6897f097 100644
--- a/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/ChatRemoteNotificationHandler_Tests.swift
@@ -36,7 +36,7 @@ final class ChatRemoteNotificationHandler_Tests: XCTestCase {
var env = ChatClient.Environment()
env.databaseContainerBuilder = { _, _ in self.database }
- env.apiClientBuilder = { _, _, _, _, _ in self.apiClient }
+ env.apiClientBuilder = { _, _, _, _, _, _ in self.apiClient }
env.messageRepositoryBuilder = { _, _ in self.messageRepository }
env.channelRepositoryBuilder = { _, _ in self.channelRepository }
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift
index 344a4b9c6a..ab89af9491 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift
@@ -37,4 +37,58 @@ final class AttachmentEndpoints_Tests: XCTestCase {
XCTAssertEqual("channels/\(id.cid.type.rawValue)/\(id.cid.id)/\(pathComponent)", endpoint.path.value)
}
}
+
+ func test_deleteAttachment_buildsCorrectly() {
+ let remoteURL = URL.unique()
+
+ let testCases: [AttachmentType: String] = [
+ .image: "image",
+ .video: "file",
+ .audio: "file",
+ .file: "file"
+ ]
+
+ for (type, pathComponent) in testCases {
+ let expectedEndpoint: Endpoint = .init(
+ path: .uploadAttachment(pathComponent),
+ method: .delete,
+ queryItems: nil,
+ requiresConnectionId: false,
+ body: ["url": remoteURL.absoluteString]
+ )
+
+ // Build endpoint
+ let endpoint: Endpoint = .deleteAttachment(url: remoteURL, type: type)
+
+ // Assert endpoint is built correctly
+ XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
+ XCTAssertEqual(endpoint.method, .delete, "Method should be DELETE for \(type)")
+ XCTAssertEqual(endpoint.path.value, "uploads/\(pathComponent)", "Path should be \(pathComponent) for \(type)")
+ XCTAssertFalse(endpoint.requiresConnectionId, "Should not require connection ID")
+
+ // Verify body contains the URL
+ let body = endpoint.body as? [String: String]
+ XCTAssertEqual(body?["url"], remoteURL.absoluteString, "Body should contain the remote URL for \(type)")
+ }
+ }
+
+ func test_deleteAttachment_imageType_usesImagePath() {
+ let remoteURL = URL.unique()
+ let endpoint: Endpoint = .deleteAttachment(url: remoteURL, type: .image)
+
+ XCTAssertEqual(endpoint.path.value, "uploads/image")
+ XCTAssertEqual(endpoint.method, .delete)
+ }
+
+ func test_deleteAttachment_nonImageType_usesFilePath() {
+ let remoteURL = URL.unique()
+ let nonImageTypes: [AttachmentType] = [.video, .audio, .file]
+
+ for type in nonImageTypes {
+ let endpoint: Endpoint = .deleteAttachment(url: remoteURL, type: type)
+
+ XCTAssertEqual(endpoint.path.value, "uploads/file", "Path should be 'file' for \(type)")
+ XCTAssertEqual(endpoint.method, .delete)
+ }
+ }
}
diff --git a/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift b/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift
index 3016db2709..16f1dbfa3d 100644
--- a/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift
@@ -359,4 +359,102 @@ final class StreamCDNClient_Tests: XCTestCase {
body: multipartFormData.getMultipartFormData()
)
}
+
+ // MARK: - Delete Attachment Tests
+
+ func test_deleteAttachmentEncoderIsCalledWithEndpoint() throws {
+ let builder = TestBuilder()
+ let client = builder.make()
+
+ // Setup mock encoder response (it's not actually used, we just need to return something)
+ let request = URLRequest(url: .unique())
+ builder.encoder.encodeRequest = .success(request)
+
+ // Create test values
+ let remoteURL = URL.unique()
+ let testEndpoint: Endpoint = .deleteAttachment(url: remoteURL, type: .file)
+
+ // Simulate file deletion
+ client.deleteAttachment(
+ remoteUrl: remoteURL,
+ completion: { _ in }
+ )
+
+ // Check the encoder is called with the correct endpoint
+ XCTAssertEqual(builder.encoder.encodeRequest_endpoints.first, AnyEndpoint(testEndpoint))
+ }
+
+ func test_deleteAttachmentEncoderFailingToEncode() throws {
+ let builder = TestBuilder()
+ let client = builder.make()
+
+ // Setup mock encoder response to fail with `testError`
+ let testError = TestError()
+ builder.encoder.encodeRequest = .failure(testError)
+
+ let remoteURL = URL.unique()
+
+ // Create a request and assert the result is failure
+ let result: Error? = try waitFor {
+ client.deleteAttachment(
+ remoteUrl: remoteURL,
+ completion: $0
+ )
+ }
+
+ XCTAssertEqual(result as? TestError, testError)
+ }
+
+ func test_deleteAttachmentSuccess() throws {
+ let builder = TestBuilder()
+ let client = builder.make()
+
+ // Create a test request and set it as a response from the encoder
+ let testRequest = URLRequest(url: .unique())
+ builder.encoder.encodeRequest = .success(testRequest)
+
+ // Set up a successful mock network response for the request (no body needed for delete)
+ URLProtocol_Mock.mockResponse(request: testRequest, statusCode: 200)
+
+ let remoteURL = URL.unique()
+
+ // Create a request and wait for the completion block
+ let result: Error? = try waitFor {
+ client.deleteAttachment(
+ remoteUrl: remoteURL,
+ completion: $0
+ )
+ }
+
+ // Check the result is successful (nil error)
+ XCTAssertNil(result)
+ }
+
+ func test_deleteAttachmentFailure() throws {
+ let builder = TestBuilder()
+ let client = builder.make()
+
+ // Create a test request and set it as a response from the encoder
+ let testRequest = URLRequest(url: .unique())
+ builder.encoder.encodeRequest = .success(testRequest)
+
+ // We cannot use `TestError` since iOS14 wraps this into another error
+ let networkError = NSError(domain: "TestNetworkError", code: -1, userInfo: nil)
+
+ // Set up a mock network response from the request with error
+ URLProtocol_Mock.mockResponse(request: testRequest, statusCode: 404, error: networkError)
+
+ let remoteURL = URL.unique()
+
+ // Create a request and wait for the completion block
+ let result: Error? = try waitFor {
+ client.deleteAttachment(
+ remoteUrl: remoteURL,
+ completion: $0
+ )
+ }
+
+ // Check the error is propagated
+ XCTAssertNotNil(result)
+ }
}
diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift
index e29e729c4f..c115481c64 100644
--- a/Tests/StreamChatTests/ChatClient_Tests.swift
+++ b/Tests/StreamChatTests/ChatClient_Tests.swift
@@ -1091,7 +1091,8 @@ private class TestEnvironment {
requestEncoder: $1,
requestDecoder: $2,
attachmentDownloader: $3,
- attachmentUploader: $4
+ attachmentUploader: $4,
+ cdnClient: $5
)
return self.apiClient!
},
From 7e59297dd47257eb7ee50bea8bd688cb8065a238 Mon Sep 17 00:00:00 2001
From: Nuno Vieira
Date: Fri, 28 Nov 2025 11:02:45 +0000
Subject: [PATCH 5/9] Fix audio recordings not using AirPods automatically
(#3884)
* Fix audio recordings not playing from AirPods automatically
* Update CHANGELOG.md
* Fix not compiling for MacOS
* Fix doc
* Use deprecated `.allowBluetooth` so that it compiles in Xcode 15
* Add comment to explain why using deprecated case
* Fix audio recording not using AirPods automatically
* Update CHANGELOG.md
* Add missing deprecated comment
---
CHANGELOG.md | 4 +-
.../Audio/AudioSessionConfiguring.swift | 46 ++----
.../Audio/AudioSessionProtocol.swift | 3 -
...StreamAudioSessionConfigurator_Tests.swift | 137 +-----------------
4 files changed, 17 insertions(+), 173 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2df65e9d60..e0d74756e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
### 🐞 Fixed
-- Fix rare crash in WebSocketClient.connectEndpoint [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
+- Fix rare crash in `WebSocketClient.connectEndpoint` [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
+- Fix audio recordings not playing from AirPods automatically [#3884](https://github.com/GetStream/stream-chat-swift/pull/3884)
+- Fix audio recordings not using AirPods mic automatically [#3884](https://github.com/GetStream/stream-chat-swift/pull/3884)
## StreamChatUI
### 🐞 Fixed
diff --git a/Sources/StreamChat/Audio/AudioSessionConfiguring.swift b/Sources/StreamChat/Audio/AudioSessionConfiguring.swift
index 8334fe18d9..15cd0b1ff7 100644
--- a/Sources/StreamChat/Audio/AudioSessionConfiguring.swift
+++ b/Sources/StreamChat/Audio/AudioSessionConfiguring.swift
@@ -68,15 +68,17 @@ open class StreamAudioSessionConfigurator: AudioSessionConfiguring {
/// Calling this method should activate the provided `AVAudioSession` for recording and playback.
///
/// - Note: This method is using the `.playAndRecord` category with the `.spokenAudio` mode.
- /// The preferredInput will be set to `.buildInMic` and overrideOutputAudioPort to `.speaker`.
open func activateRecordingSession() throws {
try audioSession.setCategory(
.playAndRecord,
mode: .spokenAudio,
policy: .default,
- options: []
+ options: [
+ // It is deprecated, but for now we need to use it,
+ // since the newer ones are not available in Xcode 15.
+ .allowBluetooth
+ ]
)
- try setUpPreferredInput(.builtInMic)
try activateSession()
}
@@ -90,16 +92,18 @@ open class StreamAudioSessionConfigurator: AudioSessionConfiguring {
/// Calling this method should activate the provided `AVAudioSession` for playback and record.
///
- /// - Note: The method will check if the audioSession's category contains the `playAndRecord` capability
- /// and if it doesn't it will activate it using the `.playbackAndRecord` category and `.default` for both mode
- /// and policy. OverrideOutputAudioPort is set to `.speaker`. The `record` capability is required
- /// ensure that the output port can be set to `.speaker`.
+ /// - Note: This method uses the `.playAndRecord` category with `.default` mode and policy.
open func activatePlaybackSession() throws {
try audioSession.setCategory(
.playAndRecord,
mode: .default,
policy: .default,
- options: []
+ options: [
+ .defaultToSpeaker,
+ // It is deprecated, but for now we need to use it,
+ // since the newer ones are not available in Xcode 15.
+ .allowBluetooth
+ ]
)
try activateSession()
}
@@ -130,12 +134,10 @@ open class StreamAudioSessionConfigurator: AudioSessionConfiguring {
// MARK: - Helpers
private func activateSession() throws {
- try audioSession.overrideOutputAudioPort(.speaker)
try audioSession.setActive(true, options: [])
}
private func deactivateSession() throws {
- try audioSession.overrideOutputAudioPort(.none)
try audioSession.setActive(false, options: [])
}
@@ -161,29 +163,5 @@ open class StreamAudioSessionConfigurator: AudioSessionConfiguring {
completionHandler(permissionGranted)
}
-
- private func setUpPreferredInput(
- _ preferredInput: AVAudioSession.Port
- ) throws {
- guard
- let availableInputs = audioSession.availableInputs,
- let preferredInput = availableInputs.first(where: { $0.portType == preferredInput })
- else {
- throw AudioSessionConfiguratorError.noAvailableInputsFound()
- }
- try audioSession.setPreferredInput(preferredInput)
- }
}
#endif
-
-// MARK: - Errors
-
-final class AudioSessionConfiguratorError: ClientError {
- /// An unknown error occurred
- static func noAvailableInputsFound(
- file: StaticString = #file,
- line: UInt = #line
- ) -> AudioSessionConfiguratorError {
- .init("No available audio inputs found.", file, line)
- }
-}
diff --git a/Sources/StreamChat/Audio/AudioSessionProtocol.swift b/Sources/StreamChat/Audio/AudioSessionProtocol.swift
index 4327f320a7..f29e45fc8e 100644
--- a/Sources/StreamChat/Audio/AudioSessionProtocol.swift
+++ b/Sources/StreamChat/Audio/AudioSessionProtocol.swift
@@ -8,7 +8,6 @@ import AVFoundation
/// A simple protocol that abstracts the usage of AVAudioSession
protocol AudioSessionProtocol {
var category: AVAudioSession.Category { get }
- var availableInputs: [AVAudioSessionPortDescription]? { get }
func setCategory(
_ category: AVAudioSession.Category,
@@ -24,8 +23,6 @@ protocol AudioSessionProtocol {
func requestRecordPermission(_ response: @escaping (Bool) -> Void)
- func setPreferredInput(_ inPort: AVAudioSessionPortDescription?) throws
-
func overrideOutputAudioPort(_ portOverride: AVAudioSession.PortOverride) throws
}
diff --git a/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift b/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift
index 7788c76c2e..79c4f78bae 100644
--- a/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift
+++ b/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift
@@ -23,7 +23,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
func test_activateRecordingSession_setCategoryFailedToComplete() {
stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
stubAudioSession.setCategoryResult = .failure(genericError)
XCTAssertThrowsError(try subject.activateRecordingSession(), genericError)
@@ -31,52 +30,17 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
func test_activateRecordingSession_setCategoryCompletedSuccessfully() throws {
stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
try subject.activateRecordingSession()
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithCategory, .playAndRecord)
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithMode, .spokenAudio)
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithPolicy, .default)
- XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithOptions, [])
- }
-
- func test_activateRecordingSession_setUpPreferredInputFailedToCompleteDueToNoAvailableInput() {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [])
-
- XCTAssertThrowsError(try subject.activateRecordingSession()) { error in
- XCTAssertEqual("No available audio inputs found.", (error as? AudioSessionConfiguratorError)?.message)
- }
- }
-
- func test_activateRecordingSession_setUpPreferredInputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
-
- try subject.activateRecordingSession()
- }
-
- func test_activateRecordingSession_setOverrideOutputFailed() {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
- stubAudioSession.overrideOutputAudioPortResult = .failure(genericError)
-
- XCTAssertThrowsError(try subject.activateRecordingSession(), genericError)
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, .speaker)
- }
-
- func test_activateRecordingSession_setOverrideOutputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
-
- try subject.activateRecordingSession()
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, .speaker)
+ XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithOptions, [.allowBluetooth])
}
func test_activateRecordingSession_setActiveFailed() {
stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
stubAudioSession.setActiveResult = .failure(genericError)
XCTAssertThrowsError(try subject.activateRecordingSession(), genericError)
@@ -85,7 +49,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
func test_activateRecordingSession_setActiveCompletedSuccessfully() throws {
stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.stubProperty(\.availableInputs, with: [makeAvailableInput(with: .builtInMic)])
try subject.activateRecordingSession()
XCTAssertTrue(stubAudioSession.setActiveWasCalledWithActive ?? false)
@@ -93,22 +56,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
// MARK: - deactivateRecordingSession
- func test_deactivateRecordingSession_categoryIsRecord_setOverrideOutputFailed() {
- stubAudioSession.stubProperty(\.category, with: .record)
- stubAudioSession.overrideOutputAudioPortResult = .failure(genericError)
-
- XCTAssertThrowsError(try subject.deactivateRecordingSession(), genericError)
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
- func test_deactivateRecordingSession_categoryIsRecord_setOverrideOutputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .record)
-
- try subject.deactivateRecordingSession()
-
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
func test_deactivateRecordingSession_categoryIsRecord_setActiveCompletedSuccesfully() throws {
stubAudioSession.stubProperty(\.category, with: .record)
@@ -141,22 +88,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
XCTAssertFalse(stubAudioSession.setActiveWasCalledWithActive ?? true)
}
- func test_deactivateRecordingSession_categoryIsPlayAndRecord_setOverrideOutputFailed() {
- stubAudioSession.stubProperty(\.category, with: .playAndRecord)
- stubAudioSession.overrideOutputAudioPortResult = .failure(genericError)
-
- XCTAssertThrowsError(try subject.deactivateRecordingSession(), genericError)
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
- func test_deactivateRecordingSession_categoryIsPlayAndRecord_setOverrideOutputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .playAndRecord)
-
- try subject.deactivateRecordingSession()
-
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
// MARK: - activatePlaybackSession
func test_activatePlaybackSession_setCategoryFailedToComplete() {
@@ -174,23 +105,7 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithCategory, .playAndRecord)
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithMode, .default)
XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithPolicy, .default)
- XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithOptions, [])
- }
-
- func test_activatePlaybackSession_setOverrideOutputFailed() {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
- stubAudioSession.overrideOutputAudioPortResult = .failure(genericError)
-
- XCTAssertThrowsError(try subject.activatePlaybackSession(), genericError)
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, .speaker)
- }
-
- func test_activatePlaybackSession_setOverrideOutputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .soloAmbient)
-
- try subject.activatePlaybackSession()
-
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, .speaker)
+ XCTAssertEqual(stubAudioSession.setCategoryWasCalledWithOptions, [.defaultToSpeaker, .allowBluetooth])
}
func test_activatePlaybackSession_setActiveFailed() {
@@ -226,22 +141,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
XCTAssertFalse(stubAudioSession.setActiveWasCalledWithActive ?? true)
}
- func test_deactivatePlaybackSession_categoryIsPlayback_setOverrideOutputFailed() {
- stubAudioSession.stubProperty(\.category, with: .playAndRecord)
- stubAudioSession.overrideOutputAudioPortResult = .failure(genericError)
-
- XCTAssertThrowsError(try subject.deactivatePlaybackSession(), genericError)
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
- func test_deactivatePlaybackSession_categoryIsPlayback_setOverrideOutputCompletedSuccessfully() throws {
- stubAudioSession.stubProperty(\.category, with: .playAndRecord)
-
- try subject.deactivatePlaybackSession()
-
- XCTAssertEqual(stubAudioSession.overrideOutputAudioPortWasCalledWithPortOverride, AVAudioSession.PortOverride.none)
- }
-
func test_deactivatePlaybackSession_categoryIsPlayAndRecord_setActiveCompletedSuccesfully() throws {
stubAudioSession.stubProperty(\.category, with: .playAndRecord)
@@ -265,16 +164,6 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase {
XCTAssertNotNil(stubAudioSession.requestRecordPermissionWasCalledWithResponse)
}
-
- // MARK: - Private Helpers
-
- private func makeAvailableInput(
- with portType: AVAudioSession.Port
- ) -> AVAudioSessionPortDescription {
- let result = StubAVAudioSessionPortDescription()
- result.stubProperty(\.portType, with: portType)
- return result
- }
}
@dynamicMemberLookup
@@ -282,7 +171,6 @@ private final class StubAVAudioSession: AudioSessionProtocol, Stub {
var stubbedProperties: [String: Any] = [:]
@objc var category: AVAudioSession.Category { self[dynamicMember: \.category] }
- @objc var availableInputs: [AVAudioSessionPortDescription]? { self[dynamicMember: \.availableInputs] }
private(set) var setCategoryWasCalledWithCategory: AVAudioSession.Category?
private(set) var setCategoryWasCalledWithMode: AVAudioSession.Mode?
@@ -296,9 +184,6 @@ private final class StubAVAudioSession: AudioSessionProtocol, Stub {
private(set) var requestRecordPermissionWasCalledWithResponse: ((Bool) -> Void)?
var requestRecordPermissionResult: Bool = false
- private(set) var setPreferredInputWasCalledWithInPort: AVAudioSessionPortDescription?
- var setPreferredInputResult: Result = .success(())
-
private(set) var overrideOutputAudioPortWasCalledWithPortOverride: AVAudioSession.PortOverride?
var overrideOutputAudioPortResult: Result = .success(())
@@ -340,17 +225,6 @@ private final class StubAVAudioSession: AudioSessionProtocol, Stub {
response(requestRecordPermissionResult)
}
- func setPreferredInput(_ inPort: AVAudioSessionPortDescription?) throws {
- setPreferredInputWasCalledWithInPort = inPort
-
- switch setCategoryResult {
- case .success:
- break
- case let .failure(error):
- throw error
- }
- }
-
func overrideOutputAudioPort(_ portOverride: AVAudioSession.PortOverride) throws {
overrideOutputAudioPortWasCalledWithPortOverride = portOverride
switch overrideOutputAudioPortResult {
@@ -361,10 +235,3 @@ private final class StubAVAudioSession: AudioSessionProtocol, Stub {
}
}
}
-
-@dynamicMemberLookup
-private final class StubAVAudioSessionPortDescription: AVAudioSessionPortDescription, Stub, @unchecked Sendable {
- var stubbedProperties: [String: Any] = [:]
-
- override var portType: AVAudioSession.Port { self[dynamicMember: \.portType] }
-}
From fa38d7a04db9385e2bb76d438258ebbaec832bbf Mon Sep 17 00:00:00 2001
From: Toomas Vahter
Date: Mon, 1 Dec 2025 16:07:28 +0200
Subject: [PATCH 6/9] Add support for filter tags in channels (#3886)
---
.swiftlint.yml | 4 +-
CHANGELOG.md | 6 ++
.../DemoChatChannelListRouter.swift | 8 ++
.../Components/DemoChatChannelListVC.swift | 18 +++-
.../Payloads/ChannelCodingKeys.swift | 1 +
.../Payloads/ChannelEditDetailPayload.swift | 9 ++
.../Payloads/ChannelListPayload.swift | 2 +
.../ChannelController/ChannelController.swift | 6 ++
.../ChatClient+ChannelController.swift | 6 ++
.../LivestreamChannelController.swift | 1 +
.../StreamChat/Database/DTOs/ChannelDTO.swift | 5 +
.../StreamChatModel.xcdatamodel/contents | 3 +-
Sources/StreamChat/Models/Channel.swift | 9 ++
.../ChannelPayload+asModel.swift | 11 +-
.../StreamChat/Query/ChannelListQuery.swift | 4 +
Sources/StreamChat/StateLayer/Chat.swift | 6 ++
.../StateLayer/ChatClient+Factory.swift | 6 ++
.../ChannelEditDetailPayload+Unique.swift | 1 +
.../Fixtures/JSONs/Channel.json | 3 +
.../ChatChannel_Mock.swift | 2 +
.../Workers/ChannelUpdater_Mock.swift | 10 ++
.../DummyData/ChannelDetailPayload.swift | 2 +
.../TestData/DummyData/XCTestCase+Dummy.swift | 3 +
.../ChannelEditDetailPayload_Tests.swift | 7 +-
.../Payloads/ChannelListPayload_Tests.swift | 10 +-
.../Payloads/IdentifiablePayload_Tests.swift | 1 +
.../ChannelController_Tests.swift | 3 +
.../LivestreamChannelController_Tests.swift | 2 +
.../ChannelListController_Tests.swift | 17 +++
.../Query/ChannelListFilterScope_Tests.swift | 2 +
.../Query/ChannelQuery_Tests.swift | 3 +
.../StateLayer/Chat_Tests.swift | 100 ++++++++++++++++++
.../ChannelReadUpdaterMiddleware_Tests.swift | 1 +
33 files changed, 261 insertions(+), 11 deletions(-)
diff --git a/.swiftlint.yml b/.swiftlint.yml
index 485ef7d104..2a1dfd6cd6 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -67,8 +67,8 @@ file_name_no_space:
cyclomatic_complexity:
ignores_case_statements: true
- warning: 25
- error: 30
+ warning: 30
+ error: 35
custom_rules:
regular_constraints_forbidden:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0d74756e6..4001223096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
+- Add support for filter tags in channels [#3886](https://github.com/GetStream/stream-chat-swift/pull/3886)
+ - Add `ChatChannel.filterTags`
+ - Add `filterTags` channel list filtering key
+ - Add `filterTags` argument to `ChatChannelController` and `Chat` factory methods in `ChatClient`
+ - Add `filterTags` argument to `ChatChannelController.updateChannel` and `ChatChannelController.partialUpdateChannel`
+ - Add `filterTags` argument to `Chat.update` and `Chat.updatePartial`
### 🐞 Fixed
- Fix rare crash in `WebSocketClient.connectEndpoint` [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
- Fix audio recordings not playing from AirPods automatically [#3884](https://github.com/GetStream/stream-chat-swift/pull/3884)
diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
index 45f1768172..21d8ff439a 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
@@ -477,6 +477,14 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
+ .init(title: "Add Premium Tag", isEnabled: canUpdateChannel, handler: { [unowned self] _ in
+ channelController.partialChannelUpdate(filterTags: ["premium"]) { error in
+ if let error = error {
+ self.rootViewController.presentAlert(title: "Couldn't make the channel \(cid) premium", message: "\(error)")
+ }
+ }
+ }),
+
.init(title: "Unmute channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.unmuteChannel { error in
if let error = error {
diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
index df1f1dfda4..4ac20e65a4 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
@@ -107,6 +107,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
lazy var equalMembersQuery: ChannelListQuery = .init(filter:
.equal(.members, values: [currentUserId, "r2-d2"])
)
+
+ lazy var premiumTaggedChannelsQuery: ChannelListQuery = .init(filter: .in(.filterTags, values: ["premium"]))
var demoRouter: DemoChatChannelListRouter? {
router as? DemoChatChannelListRouter
@@ -256,6 +258,15 @@ final class DemoChatChannelListVC: ChatChannelListVC {
self?.title = "R2-D2 Channels (Equal Members)"
self?.setEqualMembersChannelsQuery()
}
+
+ let taggedChannelsAction = UIAlertAction(
+ title: "Premium Tagged Channels",
+ style: .default,
+ handler: { [weak self] _ in
+ self?.title = "Premium Tagged Channels"
+ self?.setPremiumTaggedChannelsQuery()
+ }
+ )
presentAlert(
title: "Filter Channels",
@@ -271,7 +282,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
pinnedChannelsAction,
archivedChannelsAction,
equalMembersAction,
- channelRoleChannelsAction
+ channelRoleChannelsAction,
+ taggedChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
sourceView: filterChannelsButton
@@ -327,6 +339,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
func setEqualMembersChannelsQuery() {
replaceQuery(equalMembersQuery)
}
+
+ func setPremiumTaggedChannelsQuery() {
+ replaceQuery(premiumTaggedChannelsQuery)
+ }
func setInitialChannelsQuery() {
replaceQuery(initialQuery)
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift
index 55d57d980a..853a686866 100644
--- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift
@@ -42,6 +42,7 @@ public enum ChannelCodingKeys: String, CodingKey, CaseIterable {
case members
/// Invites.
case invites
+ case filterTags = "filter_tags"
/// The team the channel belongs to.
case team
case memberCount = "member_count"
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelEditDetailPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelEditDetailPayload.swift
index f8a04823dd..476dd84e23 100644
--- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelEditDetailPayload.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelEditDetailPayload.swift
@@ -12,6 +12,7 @@ struct ChannelEditDetailPayload: Encodable {
let team: String?
let members: Set
let invites: Set
+ let filterTags: Set
let extraData: [String: RawJSON]
init(
@@ -21,6 +22,7 @@ struct ChannelEditDetailPayload: Encodable {
team: String?,
members: Set,
invites: Set,
+ filterTags: Set,
extraData: [String: RawJSON]
) {
id = cid.id
@@ -30,6 +32,7 @@ struct ChannelEditDetailPayload: Encodable {
self.team = team
self.members = members
self.invites = invites
+ self.filterTags = filterTags
self.extraData = extraData
}
@@ -40,6 +43,7 @@ struct ChannelEditDetailPayload: Encodable {
team: String?,
members: Set,
invites: Set,
+ filterTags: Set,
extraData: [String: RawJSON]
) {
id = nil
@@ -49,6 +53,7 @@ struct ChannelEditDetailPayload: Encodable {
self.team = team
self.members = members
self.invites = invites
+ self.filterTags = filterTags
self.extraData = extraData
}
@@ -63,6 +68,10 @@ struct ChannelEditDetailPayload: Encodable {
allMembers = allMembers.union(invites)
try container.encode(invites, forKey: .invites)
}
+
+ if !filterTags.isEmpty {
+ try container.encode(filterTags, forKey: .filterTags)
+ }
if !allMembers.isEmpty {
try container.encode(allMembers, forKey: .members)
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
index 720f16212d..914945a9f9 100644
--- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
@@ -127,6 +127,7 @@ struct ChannelDetailPayload {
let createdBy: UserPayload?
/// A config.
let config: ChannelConfig
+ let filterTags: [String]?
/// The list of actions that the current user can perform in a channel.
/// It is optional, since not all events contain the own capabilities property for performance reasons.
let ownCapabilities: [String]?
@@ -188,6 +189,7 @@ extension ChannelDetailPayload: Decodable {
truncatedAt: try container.decodeIfPresent(Date.self, forKey: .truncatedAt),
createdBy: try container.decodeIfPresent(UserPayload.self, forKey: .createdBy),
config: try container.decode(ChannelConfig.self, forKey: .config),
+ filterTags: try container.decodeIfPresent([String].self, forKey: .filterTags),
ownCapabilities: try container.decodeIfPresent([String].self, forKey: .ownCapabilities),
isDisabled: try container.decode(Bool.self, forKey: .disabled),
isFrozen: try container.decode(Bool.self, forKey: .frozen),
diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
index b698114307..c1fc188604 100644
--- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
+++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
@@ -253,6 +253,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
/// - team: New team.
/// - members: New members.
/// - invites: New invites.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: New `ExtraData`.
/// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
/// If request fails, the completion will be called with an error.
@@ -263,6 +264,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
team: String?,
members: Set = [],
invites: Set = [],
+ filterTags: Set = [],
extraData: [String: RawJSON] = [:],
completion: ((Error?) -> Void)? = nil
) {
@@ -279,6 +281,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
team: team,
members: members,
invites: invites,
+ filterTags: filterTags,
extraData: extraData
)
@@ -295,6 +298,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
/// - team: New team.
/// - members: New members.
/// - invites: New invites.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: New `ExtraData`.
/// - unsetProperties: Properties from the channel that are going to be cleared/unset.
/// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
@@ -306,6 +310,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
team: String? = nil,
members: Set = [],
invites: Set = [],
+ filterTags: Set = [],
extraData: [String: RawJSON] = [:],
unsetProperties: [String] = [],
completion: ((Error?) -> Void)? = nil
@@ -323,6 +328,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
team: team,
members: members,
invites: invites,
+ filterTags: filterTags,
extraData: extraData
)
diff --git a/Sources/StreamChat/Controllers/ChannelController/ChatClient+ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChatClient+ChannelController.swift
index 9a4d4d103c..0d75d586fa 100644
--- a/Sources/StreamChat/Controllers/ChannelController/ChatClient+ChannelController.swift
+++ b/Sources/StreamChat/Controllers/ChannelController/ChatClient+ChannelController.swift
@@ -65,6 +65,7 @@ public extension ChatClient {
/// - isCurrentUserMember: If set to `true` the current user will be included into the channel. Is `true` by default.
/// - messageOrdering: Describes the ordering the messages are presented.
/// - invites: IDs for the new channel invitees.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: Extra data for the new channel.
/// - channelListQuery: The channel list query the channel this controller represents is part of.
/// - Throws: `ClientError.CurrentUserDoesNotExist` if there is no currently logged-in user.
@@ -78,6 +79,7 @@ public extension ChatClient {
isCurrentUserMember: Bool = true,
messageOrdering: MessageOrdering = .topToBottom,
invites: Set = [],
+ filterTags: Set = [],
extraData: [String: RawJSON] = [:],
channelListQuery: ChannelListQuery? = nil
) throws -> ChatChannelController {
@@ -92,6 +94,7 @@ public extension ChatClient {
team: team,
members: members.union(isCurrentUserMember ? [currentUserId] : []),
invites: invites,
+ filterTags: filterTags,
extraData: extraData
)
@@ -119,6 +122,7 @@ public extension ChatClient {
/// - name: The new channel name.
/// - imageURL: The new channel avatar URL.
/// - team: Team for the new channel.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: Extra data for the new channel.
/// - channelListQuery: The channel list query the channel this controller represents is part of.
///
@@ -134,6 +138,7 @@ public extension ChatClient {
name: String? = nil,
imageURL: URL? = nil,
team: String? = nil,
+ filterTags: Set = [],
extraData: [String: RawJSON],
channelListQuery: ChannelListQuery? = nil
) throws -> ChatChannelController {
@@ -147,6 +152,7 @@ public extension ChatClient {
team: team,
members: members.union(isCurrentUserMember ? [currentUserId] : []),
invites: [],
+ filterTags: filterTags,
extraData: extraData
)
return .init(
diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift
index 0ffafcc083..0629eacaa2 100644
--- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift
+++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift
@@ -1139,6 +1139,7 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
isHidden: event.channel.isHidden,
createdBy: event.channel.createdBy,
config: event.channel.config,
+ filterTags: event.channel.filterTags,
ownCapabilities: event.channel.ownCapabilities,
isFrozen: event.channel.isFrozen,
isDisabled: event.channel.isDisabled,
diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
index aeee63c7c6..c58513302a 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
@@ -16,6 +16,7 @@ class ChannelDTO: NSManagedObject {
@NSManaged var typeRawValue: String
@NSManaged var extraData: Data
@NSManaged var config: ChannelConfigDTO
+ @NSManaged var filterTags: [String]
@NSManaged var ownCapabilities: [String]
@NSManaged var createdAt: DBDate
@@ -255,6 +256,9 @@ extension NSManagedObjectContext {
dto.typeRawValue = payload.typeRawValue
dto.id = payload.cid.id
dto.config = payload.config.asDTO(context: self, cid: dto.cid)
+ if let filterTags = payload.filterTags {
+ dto.filterTags = filterTags
+ }
if let ownCapabilities = payload.ownCapabilities {
dto.ownCapabilities = ownCapabilities
}
@@ -645,6 +649,7 @@ extension ChatChannel {
isHidden: dto.isHidden,
createdBy: dto.createdBy?.asModel(),
config: dto.config.asModel(),
+ filterTags: Set(dto.filterTags),
ownCapabilities: Set(dto.ownCapabilities.compactMap(ChannelCapability.init(rawValue:))),
isFrozen: dto.isFrozen,
isDisabled: dto.isDisabled,
diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
index db02b92fa1..e21132df46 100644
--- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
+++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
@@ -1,5 +1,5 @@
-
+
@@ -47,6 +47,7 @@
+
diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift
index 92eecb9da1..5ed7bde35f 100644
--- a/Sources/StreamChat/Models/Channel.swift
+++ b/Sources/StreamChat/Models/Channel.swift
@@ -41,6 +41,9 @@ public struct ChatChannel {
/// A configuration struct of the channel. It contains additional information about the channel settings.
public let config: ChannelConfig
+ /// The list of tags associated with the channel.
+ public let filterTags: Set
+
/// The list of actions that the current user can perform in a channel.
public let ownCapabilities: Set
@@ -192,6 +195,7 @@ public struct ChatChannel {
isHidden: Bool,
createdBy: ChatUser? = nil,
config: ChannelConfig = .init(),
+ filterTags: Set = [],
ownCapabilities: Set = [],
isFrozen: Bool = false,
isDisabled: Bool = false,
@@ -228,6 +232,7 @@ public struct ChatChannel {
self.isHidden = isHidden
self.createdBy = createdBy
self.config = config
+ self.filterTags = filterTags
self.ownCapabilities = ownCapabilities
self.isFrozen = isFrozen
self.isDisabled = isDisabled
@@ -274,6 +279,7 @@ public struct ChatChannel {
isHidden: isHidden,
createdBy: createdBy,
config: config,
+ filterTags: filterTags,
ownCapabilities: ownCapabilities,
isFrozen: isFrozen,
isDisabled: isDisabled,
@@ -313,6 +319,7 @@ public struct ChatChannel {
isHidden: Bool? = nil,
createdBy: ChatUser? = nil,
config: ChannelConfig? = nil,
+ filterTags: Set? = nil,
ownCapabilities: Set? = nil,
isFrozen: Bool? = nil,
isDisabled: Bool? = nil,
@@ -341,6 +348,7 @@ public struct ChatChannel {
isHidden: isHidden ?? self.isHidden,
createdBy: createdBy ?? self.createdBy,
config: config ?? self.config,
+ filterTags: filterTags ?? self.filterTags,
ownCapabilities: ownCapabilities ?? self.ownCapabilities,
isFrozen: isFrozen ?? self.isFrozen,
isDisabled: isDisabled ?? self.isDisabled,
@@ -452,6 +460,7 @@ extension ChatChannel: Hashable {
guard lhs.membership == rhs.membership else { return false }
guard lhs.team == rhs.team else { return false }
guard lhs.truncatedAt == rhs.truncatedAt else { return false }
+ guard lhs.filterTags == rhs.filterTags else { return false }
guard lhs.ownCapabilities == rhs.ownCapabilities else { return false }
guard lhs.draftMessage == rhs.draftMessage else { return false }
guard lhs.activeLiveLocations.count == rhs.activeLiveLocations.count else { return false }
diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift
index ecdcbd4894..7bdda1e0e6 100644
--- a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift
+++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift
@@ -13,22 +13,22 @@ extension ChannelPayload {
unreadCount: ChannelUnreadCount?
) -> ChatChannel {
let channelPayload = channel
-
+
// Map members
let mappedMembers = members.compactMap { $0.asModel(channelId: channelPayload.cid) }
-
+
// Map latest messages
let reads = channelReads.map { $0.asModel() }
let latestMessages = messages.compactMap {
$0.asModel(cid: channel.cid, currentUserId: currentUserId, channelReads: reads)
}
-
+
// Map reads
let mappedReads = channelReads.map { $0.asModel() }
-
+
// Map watchers
let mappedWatchers = watchers?.map { $0.asModel() } ?? []
-
+
return ChatChannel(
cid: channelPayload.cid,
name: channelPayload.name,
@@ -41,6 +41,7 @@ extension ChannelPayload {
isHidden: isHidden ?? false,
createdBy: channelPayload.createdBy?.asModel(),
config: channelPayload.config,
+ filterTags: Set(channelPayload.filterTags ?? []),
ownCapabilities: Set(channelPayload.ownCapabilities?.compactMap { ChannelCapability(rawValue: $0) } ?? []),
isFrozen: channelPayload.isFrozen,
isDisabled: channelPayload.isDisabled,
diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift
index 92b1765812..cd5c67e77c 100644
--- a/Sources/StreamChat/Query/ChannelListQuery.swift
+++ b/Sources/StreamChat/Query/ChannelListQuery.swift
@@ -291,6 +291,10 @@ public extension FilterKey where Scope == ChannelListFilterScope {
/// Filter for checking if the current user has a specific channel role set.
/// Supported operatios: `equal`, `in`
static var channelRole: FilterKey { .init(rawValue: "channel_role", keyPathString: #keyPath(ChannelDTO.membership.channelRoleRaw)) }
+
+ /// A filter key for matching channel filter tags.
+ /// Supported operators: `in`, `equal`
+ static var filterTags: FilterKey { .init(rawValue: "filter_tags", keyPathString: #keyPath(ChannelDTO.filterTags), isCollectionFilter: true) }
}
/// Internal filter queries for the channel list.
diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift
index 727966a5ab..7ef3048612 100644
--- a/Sources/StreamChat/StateLayer/Chat.swift
+++ b/Sources/StreamChat/StateLayer/Chat.swift
@@ -1356,6 +1356,7 @@ public class Chat {
/// - team: The team for the channel.
/// - members: A list of members for the channel.
/// - invites: A list of users who will get invites.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: Extra data for the new channel.
///
/// - Throws: An error while communicating with the Stream API.
@@ -1365,6 +1366,7 @@ public class Chat {
team: String?,
members: Set = [],
invites: Set = [],
+ filterTags: Set = [],
extraData: [String: RawJSON] = [:]
) async throws {
try await channelUpdater.update(
@@ -1375,6 +1377,7 @@ public class Chat {
team: team,
members: members,
invites: invites,
+ filterTags: filterTags,
extraData: extraData
)
)
@@ -1391,6 +1394,7 @@ public class Chat {
/// - team: The team for the channel.
/// - members: A list of members for the channel.
/// - invites: A list of users who will get invites.
+ /// - filterTags: A list of tags to add to the channel.
/// - extraData: Extra data for the channel.
/// - unsetProperties: A list of properties to reset.
///
@@ -1401,6 +1405,7 @@ public class Chat {
team: String? = nil,
members: [UserId] = [],
invites: [UserId] = [],
+ filterTags: Set = [],
extraData: [String: RawJSON] = [:],
unsetProperties: [String] = []
) async throws {
@@ -1412,6 +1417,7 @@ public class Chat {
team: team,
members: Set(members),
invites: Set(invites),
+ filterTags: filterTags,
extraData: extraData
),
unsetProperties: unsetProperties
diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
index c01ffcf364..29ec7512ea 100644
--- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
+++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift
@@ -121,6 +121,7 @@ extension ChatClient {
/// - members: A list of members for the channel.
/// - isCurrentUserMember: If `true`, the current user is added as member.
/// - invites: A list of users who will get invites.
+ /// - filterTags: A list of tags to add to the channel.
/// - messageOrdering: Describes the ordering the messages are presented.
/// - memberSorting: The sorting order for channel members (the default sorting is by created at in ascending order).
/// - channelListQuery: The channel list query the channel belongs to.
@@ -136,6 +137,7 @@ extension ChatClient {
members: [UserId] = [],
isCurrentUserMember: Bool = true,
invites: [UserId] = [],
+ filterTags: Set = [],
messageOrdering: MessageOrdering = .topToBottom,
memberSorting: [Sorting] = [],
channelListQuery: ChannelListQuery? = nil,
@@ -149,6 +151,7 @@ extension ChatClient {
team: team,
members: Set(members).union(isCurrentUserMember ? [currentUserId] : []),
invites: Set(invites),
+ filterTags: filterTags,
extraData: extraData
)
let channelQuery = ChannelQuery(channelPayload: payload)
@@ -175,6 +178,7 @@ extension ChatClient {
/// - name: The name of the channel.
/// - imageURL: The channel avatar URL.
/// - team: The team for the channel.
+ /// - filterTags: A list of tags to add to the channel.
/// - messageOrdering: Describes the ordering the messages are presented.
/// - memberSorting: The sorting order for channel members (the default sorting is by created at in ascending order).
/// - channelListQuery: The channel list query the channel belongs to.
@@ -189,6 +193,7 @@ extension ChatClient {
name: String? = nil,
imageURL: URL? = nil,
team: String? = nil,
+ filterTags: Set = [],
messageOrdering: MessageOrdering = .topToBottom,
memberSorting: [Sorting] = [],
channelListQuery: ChannelListQuery? = nil,
@@ -203,6 +208,7 @@ extension ChatClient {
team: team,
members: Set(members).union(isCurrentUserMember ? [currentUserId] : []),
invites: [],
+ filterTags: filterTags,
extraData: extraData
)
let channelQuery = ChannelQuery(channelPayload: payload)
diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/ChannelEditDetailPayload+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/ChannelEditDetailPayload+Unique.swift
index 31caa31ce0..09aa934e37 100644
--- a/TestTools/StreamChatTestTools/Extensions/Unique/ChannelEditDetailPayload+Unique.swift
+++ b/TestTools/StreamChatTestTools/Extensions/Unique/ChannelEditDetailPayload+Unique.swift
@@ -14,6 +14,7 @@ extension ChannelEditDetailPayload {
team: .unique,
members: [],
invites: [],
+ filterTags: [],
extraData: .init()
)
}
diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json
index 45693ffcba..2258bf8a5b 100644
--- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json
+++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json
@@ -1346,6 +1346,9 @@
"uploads" : true,
"name" : "messaging"
},
+ "filter_tags": [
+ "football"
+ ],
"own_capabilities": [
"update-channel-members",
"join-channel",
diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift
index bf01781bbb..5960dfb66f 100644
--- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatChannel_Mock.swift
@@ -87,6 +87,7 @@ public extension ChatChannel {
isHidden: Bool = false,
createdBy: ChatUser? = nil,
config: ChannelConfig = .mock(),
+ filterTags: Set = [],
ownCapabilities: Set = [.sendMessage, .uploadFile],
isFrozen: Bool = false,
isDisabled: Bool = false,
@@ -123,6 +124,7 @@ public extension ChatChannel {
isHidden: isHidden,
createdBy: createdBy,
config: config,
+ filterTags: filterTags,
ownCapabilities: ownCapabilities,
isFrozen: isFrozen,
isDisabled: isDisabled,
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
index 1311233832..b703335c86 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
@@ -14,10 +14,12 @@ final class ChannelUpdater_Mock: ChannelUpdater {
@Atomic var updateChannel_payload: ChannelEditDetailPayload?
@Atomic var updateChannel_completion: ((Error?) -> Void)?
+ @Atomic var updateChannel_completion_result: Result?
@Atomic var partialChannelUpdate_updates: ChannelEditDetailPayload?
@Atomic var partialChannelUpdate_unsetProperties: [String]?
@Atomic var partialChannelUpdate_completion: ((Error?) -> Void)?
+ @Atomic var partialChannelUpdate_completion_result: Result?
@Atomic var muteChannel_cid: ChannelId?
@Atomic var muteChannel_expiration: Int?
@@ -170,6 +172,12 @@ final class ChannelUpdater_Mock: ChannelUpdater {
updateChannel_payload = nil
updateChannel_completion = nil
+ updateChannel_completion_result = nil
+
+ partialChannelUpdate_updates = nil
+ partialChannelUpdate_unsetProperties = nil
+ partialChannelUpdate_completion = nil
+ partialChannelUpdate_completion_result = nil
muteChannel_cid = nil
muteChannel_expiration = nil
@@ -330,12 +338,14 @@ final class ChannelUpdater_Mock: ChannelUpdater {
override func updateChannel(channelPayload: ChannelEditDetailPayload, completion: ((Error?) -> Void)? = nil) {
updateChannel_payload = channelPayload
updateChannel_completion = completion
+ updateChannel_completion_result?.invoke(with: completion)
}
override func partialChannelUpdate(updates: ChannelEditDetailPayload, unsetProperties: [String], completion: ((Error?) -> Void)? = nil) {
partialChannelUpdate_updates = updates
partialChannelUpdate_unsetProperties = unsetProperties
partialChannelUpdate_completion = completion
+ partialChannelUpdate_completion_result?.invoke(with: completion)
}
override func muteChannel(cid: ChannelId, expiration: Int? = nil, completion: ((Error?) -> Void)? = nil) {
diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift
index 9c3b763dfc..1636f87e60 100644
--- a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift
+++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift
@@ -20,6 +20,7 @@ extension ChannelDetailPayload {
truncatedAt: Date? = nil,
createdBy: UserPayload = .dummy(userId: .unique),
config: ChannelConfig = .mock(),
+ filterTags: [String]? = nil,
ownCapabilities: [String] = [],
isFrozen: Bool = false,
isBlocked: Bool? = false,
@@ -44,6 +45,7 @@ extension ChannelDetailPayload {
truncatedAt: truncatedAt,
createdBy: createdBy,
config: config,
+ filterTags: filterTags,
ownCapabilities: ownCapabilities,
isDisabled: isDisabled,
isFrozen: isFrozen,
diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift
index 6e7ad8191f..308b5e8558 100644
--- a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift
+++ b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift
@@ -154,6 +154,7 @@ extension XCTestCase {
createdAt: XCTestCase.channelCreatedDate,
updatedAt: .unique
),
+ filterTags: [String]? = nil,
ownCapabilities: [String] = [],
channelExtraData: [String: RawJSON] = [:],
createdAt: Date = XCTestCase.channelCreatedDate,
@@ -190,6 +191,7 @@ extension XCTestCase {
truncatedAt: truncatedAt,
createdBy: dummyUser,
config: channelConfig,
+ filterTags: filterTags,
ownCapabilities: ownCapabilities,
isDisabled: false,
isFrozen: true,
@@ -310,6 +312,7 @@ extension XCTestCase {
createdAt: XCTestCase.channelCreatedDate,
updatedAt: .unique
),
+ filterTags: nil,
ownCapabilities: [],
isDisabled: false,
isFrozen: true,
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelEditDetailPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelEditDetailPayload_Tests.swift
index 23dc8960a4..26f18ffcef 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelEditDetailPayload_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelEditDetailPayload_Tests.swift
@@ -14,6 +14,7 @@ final class ChannelEditDetailPayload_Tests: XCTestCase {
let imageURL: URL = .unique()
let team: String = .unique
let invite: UserId = .unique
+ let filterTag: String = .unique
// Create ChannelEditDetailPayload
let payload = ChannelEditDetailPayload(
@@ -23,6 +24,7 @@ final class ChannelEditDetailPayload_Tests: XCTestCase {
team: team,
members: [invite],
invites: [invite],
+ filterTags: [filterTag],
extraData: [:]
)
@@ -31,7 +33,8 @@ final class ChannelEditDetailPayload_Tests: XCTestCase {
"image": imageURL.absoluteString,
"team": team,
"members": [invite],
- "invites": [invite]
+ "invites": [invite],
+ "filter_tags": [filterTag]
]
let encodedJSON = try JSONEncoder.default.encode(payload)
@@ -50,6 +53,7 @@ final class ChannelEditDetailPayload_Tests: XCTestCase {
team: nil,
members: [.unique],
invites: [],
+ filterTags: [],
extraData: [:]
)
@@ -65,6 +69,7 @@ final class ChannelEditDetailPayload_Tests: XCTestCase {
team: nil,
members: [],
invites: [],
+ filterTags: [],
extraData: [:]
)
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
index bd5c5b17d5..3ec5279579 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
@@ -146,7 +146,10 @@ final class ChannelListPayload_Tests: XCTestCase {
],
createdAt: channelCreatedDate,
updatedAt: .unique
- ), ownCapabilities: [
+ ), filterTags: [
+ "football"
+ ],
+ ownCapabilities: [
"join-channel",
"delete-channel"
],
@@ -349,6 +352,7 @@ final class ChannelPayload_Tests: XCTestCase {
XCTAssertEqual(config.updatedAt, "2020-03-17T18:54:09.460881Z".toDate())
XCTAssertEqual(payload.membership?.user?.id, "broken-waterfall-5")
+ XCTAssertEqual(payload.channel.filterTags, ["football"])
XCTAssertEqual(payload.channel.ownCapabilities?.count, 27)
XCTAssertEqual(payload.activeLiveLocations.count, 1)
XCTAssertNotNil(payload.pushPreference)
@@ -445,6 +449,7 @@ final class ChannelPayload_Tests: XCTestCase {
truncatedAt: Date(timeIntervalSince1970: 1_609_459_250),
createdBy: createdByPayload,
config: ChannelConfig(),
+ filterTags: ["football"],
ownCapabilities: ["send-message", "upload-file"],
isDisabled: true,
isFrozen: true,
@@ -493,6 +498,7 @@ final class ChannelPayload_Tests: XCTestCase {
XCTAssertEqual(chatChannel.isHidden, true)
XCTAssertEqual(chatChannel.createdBy?.id, "creator-user-id")
XCTAssertNotNil(chatChannel.config)
+ XCTAssertEqual(chatChannel.filterTags, ["football"])
XCTAssertTrue(chatChannel.ownCapabilities.contains(.sendMessage))
XCTAssertTrue(chatChannel.ownCapabilities.contains(.uploadFile))
XCTAssertEqual(chatChannel.isFrozen, true)
@@ -542,6 +548,7 @@ final class ChannelPayload_Tests: XCTestCase {
truncatedAt: nil,
createdBy: nil,
config: ChannelConfig(),
+ filterTags: nil,
ownCapabilities: nil,
isDisabled: false,
isFrozen: false,
@@ -587,6 +594,7 @@ final class ChannelPayload_Tests: XCTestCase {
XCTAssertEqual(chatChannel.isHidden, false)
XCTAssertNil(chatChannel.createdBy)
XCTAssertNotNil(chatChannel.config)
+ XCTAssertTrue(chatChannel.filterTags.isEmpty)
XCTAssertTrue(chatChannel.ownCapabilities.isEmpty)
XCTAssertEqual(chatChannel.isFrozen, false)
XCTAssertEqual(chatChannel.isDisabled, false)
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift
index cc9c412eb0..c16486e0fc 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift
@@ -311,6 +311,7 @@ final class IdentifiablePayload_Tests: XCTestCase {
truncatedAt: nil,
createdBy: owner,
config: .mock(),
+ filterTags: nil,
ownCapabilities: [],
isDisabled: false,
isFrozen: true,
diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
index 37857fb8dd..40d335abf3 100644
--- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
@@ -4719,6 +4719,7 @@ final class ChannelController_Tests: XCTestCase {
team: nil,
members: Set(),
invites: Set(),
+ filterTags: [],
extraData: [:]
)
@@ -5835,6 +5836,7 @@ extension ChannelController_Tests {
team: nil,
members: [currentUserId, otherUserId],
invites: [],
+ filterTags: [],
extraData: [:]
)
@@ -5874,6 +5876,7 @@ extension ChannelController_Tests {
team: nil,
members: [],
invites: [],
+ filterTags: [],
extraData: [:]
)
diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift
index 3132b6aa87..60c6e4dc59 100644
--- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift
@@ -1568,6 +1568,7 @@ extension LivestreamChannelController_Tests {
isHidden: true,
createdBy: newCreatedBy,
config: .mock(),
+ filterTags: ["football"],
ownCapabilities: [.sendMessage, .readEvents],
isFrozen: true,
isDisabled: true,
@@ -1605,6 +1606,7 @@ extension LivestreamChannelController_Tests {
XCTAssertEqual(controller.channel?.isHidden, true)
XCTAssertEqual(controller.channel?.createdBy?.id, newCreatedBy.id)
XCTAssertEqual(controller.channel?.createdBy?.name, newCreatedBy.name)
+ XCTAssertEqual(controller.channel?.filterTags, ["football"])
XCTAssertTrue(controller.channel?.ownCapabilities.contains(.sendMessage) ?? false)
XCTAssertTrue(controller.channel?.ownCapabilities.contains(.readEvents) ?? false)
XCTAssertEqual(controller.channel?.isFrozen, true)
diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
index f53792abca..5af5f7db53 100644
--- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift
@@ -1943,6 +1943,23 @@ final class ChannelListController_Tests: XCTestCase {
expectedResult: [cid1, cid2]
)
}
+
+ func test_filterPredicate_filterTags_returnsExpectedResults() throws {
+ let cid1 = ChannelId.unique
+ let cid2 = ChannelId.unique
+
+ try assertFilterPredicate(
+ .in(.filterTags, values: ["premium"]),
+ channelsInDB: [
+ .dummy(channel: .dummy(cid: cid1, filterTags: ["premium"])),
+ .dummy(channel: .dummy()),
+ .dummy(channel: .dummy()),
+ .dummy(channel: .dummy()),
+ .dummy(channel: .dummy(cid: cid2, filterTags: ["ai", "premium"]))
+ ],
+ expectedResult: [cid1, cid2]
+ )
+ }
// MARK: - Private Helpers
diff --git a/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift b/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift
index 437ac8e6ee..d01117b40f 100644
--- a/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift
+++ b/Tests/StreamChatTests/Query/ChannelListFilterScope_Tests.swift
@@ -24,6 +24,7 @@ final class ChannelListFilterScope_Tests: XCTestCase {
XCTAssertEqual(Key.frozen.rawValue, ChannelCodingKeys.frozen.rawValue)
XCTAssertEqual(Key.memberCount.rawValue, ChannelCodingKeys.memberCount.rawValue)
XCTAssertEqual(Key.team.rawValue, ChannelCodingKeys.team.rawValue)
+ XCTAssertEqual(Key.filterTags.rawValue, ChannelCodingKeys.filterTags.rawValue)
// FilterKeys without corresponding ChannelCodingKeys
XCTAssertEqual(Key.createdBy.rawValue, "created_by_id")
@@ -60,6 +61,7 @@ final class ChannelListFilterScope_Tests: XCTestCase {
XCTAssertEqual(Key.muted.keyPathString, "mute")
XCTAssertEqual(Key.archived.keyPathString, "membership.archivedAt")
XCTAssertEqual(Key.pinned.keyPathString, "membership.pinnedAt")
+ XCTAssertEqual(Key.filterTags.keyPathString, "filterTags")
XCTAssertNil(Key.invite.keyPathString)
}
diff --git a/Tests/StreamChatTests/Query/ChannelQuery_Tests.swift b/Tests/StreamChatTests/Query/ChannelQuery_Tests.swift
index 8bd2d1ed1a..1f97dea71e 100644
--- a/Tests/StreamChatTests/Query/ChannelQuery_Tests.swift
+++ b/Tests/StreamChatTests/Query/ChannelQuery_Tests.swift
@@ -47,6 +47,7 @@ final class ChannelQuery_Tests: XCTestCase {
team: nil,
members: [.unique],
invites: [],
+ filterTags: [],
extraData: [:]
))
@@ -69,6 +70,7 @@ final class ChannelQuery_Tests: XCTestCase {
team: nil,
members: [.unique],
invites: [],
+ filterTags: [],
extraData: [:]
))
XCTAssertEqual(query.apiPath, "custom_type")
@@ -82,6 +84,7 @@ final class ChannelQuery_Tests: XCTestCase {
team: nil,
members: [.unique],
invites: [],
+ filterTags: [],
extraData: [:]
))
XCTAssertEqual(query.apiPath, "custom_type/id")
diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
index 68764b8195..868283418c 100644
--- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
+++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
@@ -1308,6 +1308,106 @@ final class Chat_Tests: XCTestCase {
await XCTAssertEqual(3, chat.state.channel?.reads.first?.unreadMessagesCount)
}
+ // MARK: - Updating the Channel
+
+ func test_update_whenChannelUpdaterSucceeds_thenUpdateSucceeds() async throws {
+ env.channelUpdaterMock.updateChannel_completion_result = .success(())
+ let name = "Updated Channel Name"
+ let imageURL = URL(string: "https://example.com/image.png")!
+ let team = "team123"
+ let members: Set = [.unique, .unique]
+ let invites: Set = [.unique]
+ let filterTags: Set = ["tag1", "tag2"]
+ let extraData: [String: RawJSON] = ["custom": .string("value")]
+
+ try await chat.update(
+ name: name,
+ imageURL: imageURL,
+ team: team,
+ members: members,
+ invites: invites,
+ filterTags: filterTags,
+ extraData: extraData
+ )
+
+ let payload = try XCTUnwrap(env.channelUpdaterMock.updateChannel_payload)
+ XCTAssertEqual(payload.name, name)
+ XCTAssertEqual(payload.imageURL, imageURL)
+ XCTAssertEqual(payload.team, team)
+ XCTAssertEqual(payload.members, members)
+ XCTAssertEqual(payload.invites, invites)
+ XCTAssertEqual(payload.filterTags, filterTags)
+ XCTAssertEqual(payload.extraData, extraData)
+ }
+
+ func test_update_whenChannelUpdaterFails_thenUpdateFails() async throws {
+ env.channelUpdaterMock.updateChannel_completion_result = .failure(expectedTestError)
+
+ await XCTAssertAsyncFailure(
+ try await chat.update(
+ name: "Updated Name",
+ imageURL: nil,
+ team: nil,
+ members: [],
+ invites: [],
+ filterTags: [],
+ extraData: [:]
+ ),
+ expectedTestError
+ )
+ }
+
+ func test_updatePartial_whenChannelUpdaterSucceeds_thenUpdatePartialSucceeds() async throws {
+ env.channelUpdaterMock.partialChannelUpdate_completion_result = .success(())
+ let name = "Updated Channel Name"
+ let imageURL = URL(string: "https://example.com/image.png")!
+ let team = "team123"
+ let members: [UserId] = [.unique, .unique]
+ let invites: [UserId] = [.unique]
+ let filterTags: Set = ["tag1", "tag2"]
+ let extraData: [String: RawJSON] = ["custom": .string("value")]
+ let unsetProperties = ["property1", "property2"]
+
+ try await chat.updatePartial(
+ name: name,
+ imageURL: imageURL,
+ team: team,
+ members: members,
+ invites: invites,
+ filterTags: filterTags,
+ extraData: extraData,
+ unsetProperties: unsetProperties
+ )
+
+ let payload = try XCTUnwrap(env.channelUpdaterMock.partialChannelUpdate_updates)
+ XCTAssertEqual(payload.name, name)
+ XCTAssertEqual(payload.imageURL, imageURL)
+ XCTAssertEqual(payload.team, team)
+ XCTAssertEqual(payload.members, Set(members))
+ XCTAssertEqual(payload.invites, Set(invites))
+ XCTAssertEqual(payload.filterTags, filterTags)
+ XCTAssertEqual(payload.extraData, extraData)
+ XCTAssertEqual(env.channelUpdaterMock.partialChannelUpdate_unsetProperties, unsetProperties)
+ }
+
+ func test_updatePartial_whenChannelUpdaterFails_thenUpdatePartialFails() async throws {
+ env.channelUpdaterMock.partialChannelUpdate_completion_result = .failure(expectedTestError)
+
+ await XCTAssertAsyncFailure(
+ try await chat.updatePartial(
+ name: "Updated Name",
+ imageURL: nil,
+ team: nil,
+ members: [],
+ invites: [],
+ filterTags: [],
+ extraData: [:],
+ unsetProperties: []
+ ),
+ expectedTestError
+ )
+ }
+
// MARK: - Message Replies
func test_reply_whenAPIRequestSucceeds_thenStateUpdates() async throws {
diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
index 882cfc6ff0..8d80417f66 100644
--- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
+++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
@@ -1017,6 +1017,7 @@ final class ChannelReadUpdaterMiddleware_Tests: XCTestCase {
truncatedAt: nil,
createdBy: nil,
config: .init(),
+ filterTags: nil,
ownCapabilities: [],
isDisabled: false,
isFrozen: false,
From 955f9783fd2d8cad3ccdb4ef6567f0f6a8262481 Mon Sep 17 00:00:00 2001
From: Toomas Vahter
Date: Tue, 2 Dec 2025 13:17:52 +0200
Subject: [PATCH 7/9] Mark messages as unread using timestamp (#3885)
---
CHANGELOG.md | 1 +
.../DemoChatChannelListRouter.swift | 18 ++
.../Endpoints/ChannelEndpoints.swift | 7 +-
.../Payloads/MarkUnreadPayload.swift | 34 ++++
.../ChannelController/ChannelController.swift | 42 ++++-
.../Database/DTOs/ChannelReadDTO.swift | 29 +++-
.../StreamChat/Database/DTOs/MessageDTO.swift | 23 ++-
.../StreamChat/Database/DatabaseSession.swift | 4 +-
.../Repositories/ChannelRepository.swift | 10 +-
.../Repositories/MessageRepository.swift | 30 +++-
Sources/StreamChat/StateLayer/Chat.swift | 12 +-
.../ChannelReadUpdaterMiddleware.swift | 2 +-
.../StreamChat/Workers/ChannelUpdater.swift | 6 +-
.../StreamChat/Workers/ReadStateHandler.swift | 10 +-
.../ChatChannel/ChatChannelVC.swift | 4 +
StreamChat.xcodeproj/project.pbxproj | 6 +
.../Database/DatabaseSession_Mock.swift | 4 +-
.../Repositories/ChannelRepository_Mock.swift | 6 +-
.../Workers/ChannelUpdater_Mock.swift | 8 +-
.../Endpoints/ChannelEndpoints_Tests.swift | 34 +++-
.../ChannelController_Tests.swift | 94 +++++++++-
.../Database/DTOs/ChannelReadDTO_Tests.swift | 163 +++++++++++++++++-
.../Database/DTOs/MessageDTO_Tests.swift | 8 +-
.../ChannelRepository_Tests.swift | 8 +-
.../MessageRepository_Tests.swift | 32 +++-
.../StateLayer/Chat_Tests.swift | 38 ++++
.../Workers/ChannelUpdater_Tests.swift | 8 +-
27 files changed, 565 insertions(+), 76 deletions(-)
create mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4001223096..404558a9f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
+- Add `ChatChannelController.markUnread(from:completion:)` and `Chat.markUnread(from:)` where the from argument is `Date` [#3885](https://github.com/GetStream/stream-chat-swift/pull/3885)
- Add support for filter tags in channels [#3886](https://github.com/GetStream/stream-chat-swift/pull/3886)
- Add `ChatChannel.filterTags`
- Add `filterTags` channel list filtering key
diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
index 21d8ff439a..fea829f33e 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
@@ -463,6 +463,24 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
+ .init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in
+ self.rootViewController.presentAlert(title: "Mark messages as unread with timestamp", message: "Marks messages as unread from the last number of days", textFieldPlaceholder: "Days") { offsetInDaysString in
+ let calendar = Calendar.current
+ guard let offsetInDays = Int(offsetInDaysString ?? ""),
+ let date = calendar.date(byAdding: .day, value: -abs(offsetInDays), to: calendar.startOfDay(for: Date())) else {
+ self.rootViewController.presentAlert(title: "Timestamp offset is not valid")
+ return
+ }
+ channelController.markUnread(from: date) { result in
+ switch result {
+ case .failure(let error):
+ self.rootViewController.presentAlert(title: "Couldn't mark messages as unread \(cid)", message: "\(error)")
+ case .success:
+ break
+ }
+ }
+ }
+ }),
.init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in
if let error = error {
diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
index c23256605d..f3989019a0 100644
--- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
@@ -269,16 +269,13 @@ extension Endpoint {
)
}
- static func markUnread(cid: ChannelId, messageId: MessageId, userId: UserId) -> Endpoint {
+ static func markUnread(cid: ChannelId, payload: MarkUnreadPayload) -> Endpoint {
.init(
path: .markChannelUnread(cid.apiPath),
method: .post,
queryItems: nil,
requiresConnectionId: false,
- body: [
- "message_id": messageId,
- "user_id": userId
- ]
+ body: payload
)
}
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift
new file mode 100644
index 0000000000..df1b5fc5a3
--- /dev/null
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift
@@ -0,0 +1,34 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+
+enum MarkUnreadCriteria: Sendable, Equatable {
+ /// The ID of the message from where the channel is marked unread
+ case messageId(String)
+ /// The timestamp of the message from where the channel is marked unread
+ case messageTimestamp(Date)
+}
+
+struct MarkUnreadPayload: Encodable, Sendable {
+ let criteria: MarkUnreadCriteria
+ let userId: String
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(userId, forKey: .userId)
+ switch criteria {
+ case .messageId(let messageId):
+ try container.encode(messageId, forKey: .messageId)
+ case .messageTimestamp(let messageTimestamp):
+ try container.encode(messageTimestamp, forKey: .messageTimestamp)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case messageId = "message_id"
+ case messageTimestamp = "message_timestamp"
+ case userId = "user_id"
+ }
+}
diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
index c1fc188604..bf9cdcfc18 100644
--- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
+++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
@@ -1333,7 +1333,47 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
}
readStateHandler.markUnread(
- from: messageId,
+ from: .messageId(messageId),
+ in: channel
+ ) { [weak self] result in
+ self?.callback {
+ completion?(result)
+ }
+ }
+ }
+
+ /// Marks all messages of the channel as unread that were created after the specified timestamp.
+ ///
+ /// This method finds the first message with a creation timestamp greater than to the provided timestamp,
+ /// and marks all messages from that point forward as unread. If no message is found after the timestamp,
+ /// the operation completes without error but no messages are marked as unread.
+ ///
+ /// - Parameters:
+ /// - timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread.
+ /// - completion: The completion handler to be called after marking messages as unread. Called with a `Result` containing the updated `ChatChannel` on success, or an `Error` on failure.
+ public func markUnread(from timestamp: Date, completion: ((Result) -> Void)? = nil) {
+ /// Perform action only if channel is already created on backend side and have a valid `cid`.
+ guard let channel = channel else {
+ let error = ClientError.ChannelNotCreatedYet()
+ log.error(error.localizedDescription)
+ callback {
+ completion?(.failure(error))
+ }
+ return
+ }
+
+ /// Read events are not enabled for this channel
+ guard channel.canReceiveReadEvents == true else {
+ let error = ClientError.ChannelFeatureDisabled("Channel feature: read events is disabled for this channel.")
+ log.error(error.localizedDescription)
+ callback {
+ completion?(.failure(error))
+ }
+ return
+ }
+
+ readStateHandler.markUnread(
+ from: .messageTimestamp(timestamp),
in: channel
) { [weak self] result in
self?.callback {
diff --git a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
index 7725444c28..1e6bb47664 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
@@ -138,23 +138,44 @@ extension NSManagedObjectContext {
func markChannelAsUnread(
for cid: ChannelId,
userId: UserId,
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
lastReadAt: Date?,
unreadMessagesCount: Int?
) {
- guard let read = loadChannelRead(cid: cid, userId: userId),
- let message = self.message(id: messageId) else {
- return
+ guard let read = loadChannelRead(cid: cid, userId: userId) else { return }
+
+ let findMessageDTO: () -> MessageDTO? = {
+ switch unreadCriteria {
+ case .messageId(let messageId):
+ return self.message(id: messageId)
+ case .messageTimestamp(let messageTimestamp):
+ let clientConfig = self.chatClientConfig
+ return try? MessageDTO.loadMessage(
+ beforeOrEqual: messageTimestamp,
+ cid: cid.rawValue,
+ deletedMessagesVisibility: clientConfig?.deletedMessagesVisibility ?? .alwaysVisible,
+ shouldShowShadowedMessages: clientConfig?.shouldShowShadowedMessages ?? false,
+ context: self
+ )
+ }
}
+ guard let message = findMessageDTO() else { return }
let lastReadAt = lastReadAt ?? message.createdAt.bridgeDate
read.lastReadAt = lastReadAt.bridgeDate
read.lastReadMessageId = lastReadMessageId
+ let excludesMessageId: Bool = {
+ switch unreadCriteria {
+ case .messageId: return false
+ case .messageTimestamp: return true
+ }
+ }()
let messagesCount = unreadMessagesCount ?? MessageDTO.countOtherUserMessages(
in: read.channel.cid,
createdAtFrom: lastReadAt,
+ excludingMessageId: excludesMessageId ? message.id : nil,
context: self
)
read.unreadMessageCount = Int32(messagesCount)
diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
index 4f937582ce..48c26b572b 100644
--- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
@@ -558,13 +558,17 @@ class MessageDTO: NSManagedObject {
static func countOtherUserMessages(
in cid: String,
createdAtFrom: Date,
+ excludingMessageId: MessageId?,
context: NSManagedObjectContext
) -> Int {
- let subpredicates: [NSPredicate] = [
+ var subpredicates: [NSPredicate] = [
sentMessagesPredicate(for: cid),
.init(format: "createdAt >= %@", createdAtFrom.bridgeDate),
.init(format: "user.currentUser == nil")
]
+ if let excludingMessageId {
+ subpredicates.append(.init(format: "id != %@", excludingMessageId))
+ }
let request = NSFetchRequest(entityName: MessageDTO.entityName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.defaultSortingKey, ascending: false)]
@@ -607,6 +611,23 @@ class MessageDTO: NSManagedObject {
return try context.fetch(request).first
}
+ static func loadMessage(
+ beforeOrEqual timestamp: Date,
+ cid: String,
+ deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility,
+ shouldShowShadowedMessages: Bool,
+ context: NSManagedObjectContext
+ ) throws -> MessageDTO? {
+ let request = NSFetchRequest(entityName: entityName)
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+ .init(format: "createdAt <= %@", timestamp.bridgeDate),
+ channelMessagesPredicate(for: cid, deletedMessagesVisibility: deletedMessagesVisibility, shouldShowShadowedMessages: shouldShowShadowedMessages)
+ ])
+ request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
static func loadMessages(
from fromIncludingDate: Date,
to toIncludingDate: Date,
diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift
index 5041834d92..592f031afa 100644
--- a/Sources/StreamChat/Database/DatabaseSession.swift
+++ b/Sources/StreamChat/Database/DatabaseSession.swift
@@ -396,12 +396,12 @@ protocol ChannelReadDatabaseSession {
/// Sets the channel `cid` as read for `userId`
func markChannelAsRead(cid: ChannelId, userId: UserId, at: Date)
- /// Sets the channel `cid` as unread for `userId` starting from the `messageId`
+ /// Sets the channel `cid` as unread for `userId` starting from the message id or timestamp.
/// Uses `lastReadAt` and `unreadMessagesCount` if passed, otherwise it calculates it.
func markChannelAsUnread(
for cid: ChannelId,
userId: UserId,
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
lastReadAt: Date?,
unreadMessagesCount: Int?
diff --git a/Sources/StreamChat/Repositories/ChannelRepository.swift b/Sources/StreamChat/Repositories/ChannelRepository.swift
index b46561c8ab..00794ecd3c 100644
--- a/Sources/StreamChat/Repositories/ChannelRepository.swift
+++ b/Sources/StreamChat/Repositories/ChannelRepository.swift
@@ -58,23 +58,23 @@ class ChannelRepository {
}
}
- /// Marks a subset of the messages of the channel as unread. All the following messages, including the one that is
+ /// Marks a subset of the messages of the channel as unread. All the following messages including the one that is
/// passed as parameter, will be marked as not read.
/// - Parameters:
/// - cid: The id of the channel to be marked as unread
/// - userId: The id of the current user
- /// - messageId: The id of the first message that will be marked as unread.
+ /// - unreadCriteria: The id or timestamp of the first message that will be marked as unread.
/// - lastReadMessageId: The id of the last message that was read.
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
func markUnread(
for cid: ChannelId,
userId: UserId,
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
completion: ((Result) -> Void)? = nil
) {
apiClient.request(
- endpoint: .markUnread(cid: cid, messageId: messageId, userId: userId)
+ endpoint: .markUnread(cid: cid, payload: .init(criteria: unreadCriteria, userId: userId))
) { [weak self] result in
if let error = result.error {
completion?(.failure(error))
@@ -86,7 +86,7 @@ class ChannelRepository {
session.markChannelAsUnread(
for: cid,
userId: userId,
- from: messageId,
+ from: unreadCriteria,
lastReadMessageId: lastReadMessageId,
lastReadAt: nil,
unreadMessagesCount: nil
diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift
index e6e797c8c2..78bb4a35b6 100644
--- a/Sources/StreamChat/Repositories/MessageRepository.swift
+++ b/Sources/StreamChat/Repositories/MessageRepository.swift
@@ -339,7 +339,7 @@ class MessageRepository {
/// Fetches a message id before the specified message when sorting by the creation date in the local database.
func getMessage(
- before messageId: MessageId,
+ before unreadCriteria: MarkUnreadCriteria,
in cid: ChannelId,
completion: @escaping (Result) -> Void
) {
@@ -349,14 +349,26 @@ class MessageRepository {
let deletedMessagesVisibility = clientConfig?.deletedMessagesVisibility ?? .alwaysVisible
let shouldShowShadowedMessages = clientConfig?.shouldShowShadowedMessages ?? false
do {
- let resultId = try MessageDTO.loadMessage(
- before: messageId,
- cid: cid.rawValue,
- deletedMessagesVisibility: deletedMessagesVisibility,
- shouldShowShadowedMessages: shouldShowShadowedMessages,
- context: context
- )?.id
- completion(.success(resultId))
+ switch unreadCriteria {
+ case .messageId(let messageId):
+ let resultId = try MessageDTO.loadMessage(
+ before: messageId,
+ cid: cid.rawValue,
+ deletedMessagesVisibility: deletedMessagesVisibility,
+ shouldShowShadowedMessages: shouldShowShadowedMessages,
+ context: context
+ )?.id
+ completion(.success(resultId))
+ case .messageTimestamp(let messageTimestamp):
+ let resultId = try MessageDTO.loadMessage(
+ beforeOrEqual: messageTimestamp,
+ cid: cid.rawValue,
+ deletedMessagesVisibility: deletedMessagesVisibility,
+ shouldShowShadowedMessages: shouldShowShadowedMessages,
+ context: context
+ )?.id
+ completion(.success(resultId))
+ }
} catch {
completion(.failure(error))
}
diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift
index 7ef3048612..ee9415bad4 100644
--- a/Sources/StreamChat/StateLayer/Chat.swift
+++ b/Sources/StreamChat/StateLayer/Chat.swift
@@ -936,7 +936,17 @@ public class Chat {
/// - Throws: An error while communicating with the Stream API.
public func markUnread(from messageId: MessageId) async throws {
guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() }
- try await readStateHandler.markUnread(from: messageId, in: channel)
+ try await readStateHandler.markUnread(from: .messageId(messageId), in: channel)
+ }
+
+ /// Marks all the messages after the specified timestamp as unread.
+ ///
+ /// - Parameter timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread.
+ ///
+ /// - Throws: An error while communicating with the Stream API.
+ public func markUnread(from timestamp: Date) async throws {
+ guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() }
+ try await readStateHandler.markUnread(from: .messageTimestamp(timestamp), in: channel)
}
// MARK: - Message Replies and Pagination
diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
index 7b6cf56275..0f1b30a880 100644
--- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
+++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
@@ -123,7 +123,7 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware {
session.markChannelAsUnread(
for: cid,
userId: userId,
- from: messageId,
+ from: .messageId(messageId),
lastReadMessageId: lastReadMessageId,
lastReadAt: lastReadAt,
unreadMessagesCount: unreadMessages
diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift
index 9c8d33b998..24c1008682 100644
--- a/Sources/StreamChat/Workers/ChannelUpdater.swift
+++ b/Sources/StreamChat/Workers/ChannelUpdater.swift
@@ -564,20 +564,20 @@ class ChannelUpdater: Worker {
/// - Parameters:
/// - cid: The id of the channel to be marked as unread
/// - userId: The id of the current user
- /// - messageId: The id of the first message id that will be marked as unread.
+ /// - unreadCriteria: The id or timestamp of the first message that will be marked as unread.
/// - lastReadMessageId: The id of the last message that was read.
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
func markUnread(
cid: ChannelId,
userId: UserId,
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
completion: ((Result) -> Void)? = nil
) {
channelRepository.markUnread(
for: cid,
userId: userId,
- from: messageId,
+ from: unreadCriteria,
lastReadMessageId: lastReadMessageId,
completion: completion
)
diff --git a/Sources/StreamChat/Workers/ReadStateHandler.swift b/Sources/StreamChat/Workers/ReadStateHandler.swift
index 888c9edec2..172b12ac83 100644
--- a/Sources/StreamChat/Workers/ReadStateHandler.swift
+++ b/Sources/StreamChat/Workers/ReadStateHandler.swift
@@ -46,7 +46,7 @@ final class ReadStateHandler {
}
func markUnread(
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
in channel: ChatChannel,
completion: @escaping (Result) -> Void
) {
@@ -57,13 +57,13 @@ final class ReadStateHandler {
return
}
markingRead = true
- messageRepository.getMessage(before: messageId, in: channel.cid) { [weak self] result in
+ messageRepository.getMessage(before: unreadCriteria, in: channel.cid) { [weak self] result in
switch result {
case .success(let lastReadMessageId):
self?.channelUpdater.markUnread(
cid: channel.cid,
userId: currentUserId,
- from: messageId,
+ from: unreadCriteria,
lastReadMessageId: lastReadMessageId
) { [weak self] result in
if case .success = result {
@@ -80,12 +80,12 @@ final class ReadStateHandler {
}
func markUnread(
- from messageId: MessageId,
+ from unreadCriteria: MarkUnreadCriteria,
in channel: ChatChannel
) async throws {
try await withCheckedThrowingContinuation { continuation in
markUnread(
- from: messageId,
+ from: unreadCriteria,
in: channel
) { result in
continuation.resume(with: result.error)
diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
index 63a5173569..80e6347159 100644
--- a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
+++ b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
@@ -598,6 +598,10 @@ open class ChatChannelVC: _ViewController,
if let event = event as? MessageDeliveredEvent, event.cid == channelController.cid, !messages.isEmpty {
messageListVC.listView.reloadRows(at: [.init(item: 0, section: 0)], with: .none)
}
+
+ if let event = event as? NotificationMarkUnreadEvent, let channel = channelController.channel, event.cid == channelController.cid, !messages.isEmpty {
+ updateAllUnreadMessagesRelatedComponents(channel: channel)
+ }
}
// MARK: - AudioQueuePlayerDatasource
diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj
index 91f358ec03..ea811b8e77 100644
--- a/StreamChat.xcodeproj/project.pbxproj
+++ b/StreamChat.xcodeproj/project.pbxproj
@@ -318,6 +318,8 @@
4F97F27B2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; };
4FB4AB9F2BAD6DBD00712C4E /* Chat_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */; };
4FBD840B2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */; };
+ 4FC7B3F02ED86E3000246903 /* MarkUnreadPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */; };
+ 4FC7B3F12ED86E3000246903 /* MarkUnreadPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */; };
4FCCACE42BC939EB009D23E1 /* MemberList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */; };
4FD2BE502B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; };
4FD2BE512B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; };
@@ -3360,6 +3362,7 @@
4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSearchState+Observer.swift"; sourceTree = ""; };
4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat_Tests.swift; sourceTree = ""; };
4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloader_Spy.swift; sourceTree = ""; };
+ 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkUnreadPayload.swift; sourceTree = ""; };
4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberList_Tests.swift; sourceTree = ""; };
4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateHandler.swift; sourceTree = ""; };
4FD2BE522B9AEE3500FFC6F2 /* StreamCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCollection.swift; sourceTree = ""; };
@@ -5976,6 +5979,7 @@
79682C4724BF37550071578E /* Payloads */ = {
isa = PBXGroup;
children = (
+ 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */,
AD8513782E9D3013005327C0 /* ChannelDeliveredPayload.swift */,
AD25F7422E84667900F16B14 /* PushPreferencePayloads.swift */,
ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */,
@@ -11911,6 +11915,7 @@
8413D2E92BDC6300005ADA4E /* PollVoteListQueryDTO.swift in Sources */,
DAD539DB250B8A9C00CFC649 /* Controller.swift in Sources */,
F6ED5F76250278D7005D7327 /* SyncEndpoint.swift in Sources */,
+ 4FC7B3F12ED86E3000246903 /* MarkUnreadPayload.swift in Sources */,
AD9490572BF3BA9600E69224 /* ThreadListController.swift in Sources */,
AD8FEE582AA8E1A100273F88 /* ChatClient+Environment.swift in Sources */,
841BAA512BD1CD81000C73E4 /* PollDTO.swift in Sources */,
@@ -13086,6 +13091,7 @@
C121E8E3274544B200023E4C /* Data+Gzip.swift in Sources */,
C121E8E4274544B200023E4C /* LazyCachedMapCollection.swift in Sources */,
40789D3D29F6AD9C0018C2BB /* Debouncer.swift in Sources */,
+ 4FC7B3F02ED86E3000246903 /* MarkUnreadPayload.swift in Sources */,
AD7A11CC2DEE091400B8F963 /* LocationEndpoints.swift in Sources */,
AD6E32A22BBC50110073831B /* ThreadListQuery.swift in Sources */,
ADF0473A2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */,
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
index 7eb26fa3bf..a133ac6089 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
@@ -360,11 +360,11 @@ class DatabaseSession_Mock: DatabaseSession {
underlyingSession.markChannelAsUnread(cid: cid, by: userId)
}
- func markChannelAsUnread(for cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int?) {
+ func markChannelAsUnread(for cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int?) {
underlyingSession.markChannelAsUnread(
for: cid,
userId: userId,
- from: messageId,
+ from: unreadCriteria,
lastReadMessageId: lastReadMessageId,
lastReadAt: lastReadAt,
unreadMessagesCount: unreadMessagesCount
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift
index 5e96cd2131..02674bd5d7 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift
@@ -16,7 +16,7 @@ class ChannelRepository_Mock: ChannelRepository, Spy {
var markUnreadCid: ChannelId?
var markUnreadUserId: UserId?
- var markUnreadMessageId: UserId?
+ var markUnreadCriteria: MarkUnreadCriteria?
var markUnreadLastReadMessageId: UserId?
var markUnreadResult: Result?
@@ -36,11 +36,11 @@ class ChannelRepository_Mock: ChannelRepository, Spy {
}
}
- override func markUnread(for cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) {
+ override func markUnread(for cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) {
record()
markUnreadCid = cid
markUnreadUserId = userId
- markUnreadMessageId = messageId
+ markUnreadCriteria = unreadCriteria
markUnreadLastReadMessageId = lastReadMessageId
markUnreadResult.map {
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
index b703335c86..2d9b9012e6 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift
@@ -104,7 +104,7 @@ final class ChannelUpdater_Mock: ChannelUpdater {
@Atomic var markUnread_cid: ChannelId?
@Atomic var markUnread_userId: UserId?
- @Atomic var markUnread_messageId: MessageId?
+ @Atomic var markUnread_criteria: MarkUnreadCriteria?
@Atomic var markUnread_lastReadMessageId: MessageId?
@Atomic var markUnread_completion: ((Result) -> Void)?
@Atomic var markUnread_completion_result: Result?
@@ -257,7 +257,7 @@ final class ChannelUpdater_Mock: ChannelUpdater {
markUnread_cid = nil
markUnread_userId = nil
- markUnread_messageId = nil
+ markUnread_criteria = nil
markUnread_lastReadMessageId = nil
markUnread_completion = nil
markUnread_completion_result = nil
@@ -522,10 +522,10 @@ final class ChannelUpdater_Mock: ChannelUpdater {
markRead_completion_result?.invoke(with: completion)
}
- override func markUnread(cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) {
+ override func markUnread(cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) {
markUnread_cid = cid
markUnread_userId = userId
- markUnread_messageId = messageId
+ markUnread_criteria = unreadCriteria
markUnread_lastReadMessageId = lastReadMessageId
markUnread_completion = completion
markUnread_completion_result?.invoke(with: completion)
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift
index 1b6f835be3..dac968915b 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift
@@ -449,7 +449,7 @@ final class ChannelEndpoints_Tests: XCTestCase {
XCTAssertEqual("channels/\(cid.type.rawValue)/\(cid.id)/read", endpoint.path.value)
}
- func test_markUnread_buildsCorrectly() {
+ func test_markUnreadWithMessageId_buildsCorrectly() {
let cid = ChannelId.unique
let messageId = MessageId.unique
let userId = UserId.unique
@@ -459,13 +459,35 @@ final class ChannelEndpoints_Tests: XCTestCase {
method: .post,
queryItems: nil,
requiresConnectionId: false,
- body: [
- "message_id": messageId,
- "user_id": userId
- ]
+ body: MarkUnreadPayload(
+ criteria: .messageId(messageId),
+ userId: userId
+ )
+ )
+
+ let endpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId))
+
+ XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
+ XCTAssertEqual(endpoint.path.value, "channels/\(cid.type.rawValue)/\(cid.id)/unread")
+ }
+
+ func test_markUnreadWithTimestamp_buildsCorrectly() {
+ let cid = ChannelId.unique
+ let messageTimestamp = Date.unique
+ let userId = UserId.unique
+
+ let expectedEndpoint = Endpoint(
+ path: .markChannelUnread(cid.apiPath),
+ method: .post,
+ queryItems: nil,
+ requiresConnectionId: false,
+ body: MarkUnreadPayload(
+ criteria: .messageTimestamp(messageTimestamp),
+ userId: userId
+ )
)
- let endpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId)
+ let endpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageTimestamp(messageTimestamp), userId: userId))
XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
XCTAssertEqual(endpoint.path.value, "channels/\(cid.type.rawValue)/\(cid.id)/unread")
diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
index 40d335abf3..e56906490f 100644
--- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
+++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift
@@ -4135,7 +4135,7 @@ final class ChannelController_Tests: XCTestCase {
func test_markUnread_whenChannelDoesNotExist() {
var receivedError: Error?
let expectation = self.expectation(description: "Mark Unread completes")
- controller.markUnread(from: .unique) { result in
+ controller.markUnread(from: MessageId.unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -4156,7 +4156,7 @@ final class ChannelController_Tests: XCTestCase {
var receivedError: Error?
let expectation = self.expectation(description: "Mark Unread completes")
- controller.markUnread(from: .unique) { result in
+ controller.markUnread(from: MessageId.unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -4211,7 +4211,7 @@ final class ChannelController_Tests: XCTestCase {
var receivedError: Error?
let expectation = self.expectation(description: "Mark Unread completes")
- controller.markUnread(from: .unique) { result in
+ controller.markUnread(from: MessageId.unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -4232,7 +4232,7 @@ final class ChannelController_Tests: XCTestCase {
var receivedError: Error?
let expectation = self.expectation(description: "Mark Unread completes")
- controller.markUnread(from: .unique) { result in
+ controller.markUnread(from: MessageId.unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -4255,7 +4255,7 @@ final class ChannelController_Tests: XCTestCase {
env.channelUpdater?.markUnread_completion_result = .failure(mockedError)
var receivedError: Error?
let expectation = self.expectation(description: "Mark Unread completes")
- controller.markUnread(from: .unique) { result in
+ controller.markUnread(from: MessageId.unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -4291,7 +4291,7 @@ final class ChannelController_Tests: XCTestCase {
// Because we don't have other messages, we fallback to the passed messageId as lastReadMessageId.
XCTAssertNil(updater.markUnread_lastReadMessageId)
- XCTAssertEqual(updater.markUnread_messageId, messageId)
+ XCTAssertEqual(updater.markUnread_criteria, MarkUnreadCriteria.messageId(messageId))
}
func test_markUnread_whenIsNotMarkingAsRead_andCurrentUserIdIsPresent_whenThereAreOtherMessages_whenUpdaterSucceeds() throws {
@@ -4324,7 +4324,87 @@ final class ChannelController_Tests: XCTestCase {
XCTAssertNil(receivedError)
XCTAssertEqual(updater.markUnread_lastReadMessageId, previousMessageId)
- XCTAssertEqual(updater.markUnread_messageId, messageId)
+ XCTAssertEqual(updater.markUnread_criteria, MarkUnreadCriteria.messageId(messageId))
+ }
+
+ func test_markUnread_whenChannelDoesNotExist_messageTimestamp() {
+ var receivedError: Error?
+ let expectation = self.expectation(description: "Mark Unread completes")
+ controller.markUnread(from: Date()) { result in
+ receivedError = result.error
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertTrue(receivedError is ClientError.ChannelNotCreatedYet)
+ }
+
+ func test_markUnread_whenReadEventsAreNotEnabled_messageTimestamp() throws {
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: channelId, ownCapabilities: [])
+ )
+
+ writeAndWaitForMessageUpdates(count: 0, channelChanges: true) { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ var receivedError: Error?
+ let expectation = self.expectation(description: "Mark Unread completes")
+ controller.markUnread(from: Date()) { result in
+ receivedError = result.error
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertTrue(receivedError is ClientError.ChannelFeatureDisabled)
+ }
+
+ func test_markUnread_whenIsMarkingAsRead_andCurrentUserIdIsPresent_messageTimestamp() throws {
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: channelId, ownCapabilities: [ChannelCapability.readEvents.rawValue])
+ )
+
+ try client.databaseContainer.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ let currentUserId = UserId.unique
+ client.setToken(token: .unique(userId: currentUserId))
+ try simulateMarkingAsRead(userId: currentUserId)
+
+ var receivedError: Error?
+ let expectation = self.expectation(description: "Mark Unread completes")
+ controller.markUnread(from: Date()) { result in
+ receivedError = result.error
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertNil(receivedError)
+ }
+
+ func test_markUnread_whenIsNotMarkingAsRead_andCurrentUserIdIsNotPresent_messageTimestamp() throws {
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: channelId, ownCapabilities: [ChannelCapability.readEvents.rawValue])
+ )
+
+ writeAndWaitForMessageUpdates(count: 0, channelChanges: true) { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ var receivedError: Error?
+ let expectation = self.expectation(description: "Mark Unread completes")
+ controller.markUnread(from: Date()) { result in
+ receivedError = result.error
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: defaultTimeout)
+
+ XCTAssertNil(receivedError)
}
// MARK: - Load more channel reads
diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift
index 0724371282..f582bee10e 100644
--- a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift
+++ b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift
@@ -321,7 +321,23 @@ final class ChannelReadDTO_Tests: XCTestCase {
// WHEN
try database.writeSynchronously { session in
- session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
+ }
+
+ // THEN
+ XCTAssertEqual(database.writeSessionCounter, 1)
+ XCTAssertNil(readDTO(cid: cid, userId: userId))
+ }
+
+ func test_markChannelAsUnreadPartial_whenReadDoesNotExist_messageTimestamp() throws {
+ // GIVEN
+ let cid = ChannelId.unique
+ let userId = UserId.unique
+ let messageDate = Date()
+
+ // WHEN
+ try database.writeSynchronously { session in
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(messageDate), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
}
// THEN
@@ -359,7 +375,7 @@ final class ChannelReadDTO_Tests: XCTestCase {
// WHEN
try database.writeSynchronously { session in
- session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
}
// THEN
@@ -367,6 +383,44 @@ final class ChannelReadDTO_Tests: XCTestCase {
XCTAssertNotNil(readDTO(cid: cid, userId: userId))
XCTAssertNil(database.viewContext.message(id: messageId))
}
+
+ func test_markChannelAsUnreadPartial_whenMessageDoesNotExist_messageTimestamp() throws {
+ // GIVEN
+ let cid = ChannelId.unique
+ let userId = UserId.unique
+ let messageDate = Date()
+
+ let member: MemberPayload = .dummy(user: .dummy(userId: userId))
+ let read = ChannelReadPayload(
+ user: member.user!,
+ lastReadAt: .init(),
+ lastReadMessageId: .unique,
+ unreadMessagesCount: 10,
+ lastDeliveredAt: nil,
+ lastDeliveredMessageId: nil
+ )
+
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: cid),
+ members: [member],
+ channelReads: [read]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ database.writeSessionCounter = 0
+
+ // WHEN
+ try database.writeSynchronously { session in
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(messageDate), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil)
+ }
+
+ // THEN
+ XCTAssertEqual(database.writeSessionCounter, 1)
+ XCTAssertNotNil(readDTO(cid: cid, userId: userId))
+ }
func test_markChannelAsUnreadPartial_whenMessagesExist_shouldUpdateReads() throws {
// GIVEN
@@ -404,7 +458,7 @@ final class ChannelReadDTO_Tests: XCTestCase {
// WHEN
try database.writeSynchronously { session in
- session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil)
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil)
}
// THEN
@@ -415,6 +469,52 @@ final class ChannelReadDTO_Tests: XCTestCase {
XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId)
XCTAssertNotNil(database.viewContext.message(id: messageId))
}
+
+ func test_markChannelAsUnreadPartial_whenMessagesExist_shouldUpdateReads_messageTimestamp() throws {
+ // GIVEN
+ let cid = ChannelId.unique
+ let userId = UserId.unique
+ let lastReadMessageId = MessageId.unique
+
+ let member: MemberPayload = .dummy(user: .dummy(userId: userId))
+ let read = ChannelReadPayload(
+ user: member.user!,
+ lastReadAt: .init(),
+ lastReadMessageId: .unique,
+ unreadMessagesCount: 10,
+ lastDeliveredAt: nil,
+ lastDeliveredMessageId: nil
+ )
+ let firstMessageDate = Date()
+ let messages: [MessagePayload] = [.unique, .unique, .unique].enumerated().map { index, id in
+ MessagePayload.dummy(messageId: id, authorUserId: .unique, createdAt: firstMessageDate.addingTimeInterval(TimeInterval(index)))
+ }
+
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: cid),
+ members: [member],
+ messages: messages,
+ channelReads: [read]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ database.writeSessionCounter = 0
+
+ // WHEN - Use the first message's timestamp
+ try database.writeSynchronously { session in
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(firstMessageDate), lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil)
+ }
+
+ // THEN
+ XCTAssertEqual(database.writeSessionCounter, 1)
+ let readDTO = try XCTUnwrap(readDTO(cid: cid, userId: userId))
+ XCTAssertNearlySameDate(readDTO.lastReadAt.bridgeDate, firstMessageDate)
+ XCTAssertEqual(readDTO.unreadMessageCount, 2)
+ XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId)
+ }
func test_markChannelAsUnreadPartial_whenMessagesExist_lastReadAndUnreadMessagesAreSent_shouldUpdateWithArgumentValue() throws {
// GIVEN
@@ -455,7 +555,7 @@ final class ChannelReadDTO_Tests: XCTestCase {
// WHEN
try database.writeSynchronously { session in
- session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount)
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount)
}
// THEN
@@ -473,6 +573,61 @@ final class ChannelReadDTO_Tests: XCTestCase {
XCTAssertNotNil(database.viewContext.message(id: messageId))
}
+
+ func test_markChannelAsUnreadPartial_whenMessagesExist_lastReadAndUnreadMessagesAreSent_shouldUpdateWithArgumentValue_messageTimestamp() throws {
+ // GIVEN
+ let cid = ChannelId.unique
+ let userId = UserId.unique
+ let lastReadMessageId = MessageId.unique
+
+ let member: MemberPayload = .dummy(user: .dummy(userId: userId))
+ let read = ChannelReadPayload(
+ user: member.user!,
+ lastReadAt: .init(),
+ lastReadMessageId: .unique,
+ unreadMessagesCount: 10,
+ lastDeliveredAt: nil,
+ lastDeliveredMessageId: nil
+ )
+ let firstMessageDate = Date()
+ let messages: [MessagePayload] = [.unique, .unique, .unique].enumerated().map { index, id in
+ MessagePayload.dummy(messageId: id, authorUserId: .unique, createdAt: firstMessageDate.addingTimeInterval(TimeInterval(index)))
+ }
+
+ let channel: ChannelPayload = .dummy(
+ channel: .dummy(cid: cid),
+ members: [member],
+ messages: messages,
+ channelReads: [read]
+ )
+
+ try database.writeSynchronously { session in
+ try session.saveChannel(payload: channel)
+ }
+
+ database.writeSessionCounter = 0
+
+ let passedLastReadAt = Date().addingTimeInterval(-1000)
+ let passedUnreadMessagesCount = 100
+
+ // WHEN - Use the first message's timestamp
+ try database.writeSynchronously { session in
+ session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(firstMessageDate), lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount)
+ }
+
+ // THEN
+ XCTAssertEqual(database.writeSessionCounter, 1)
+ let readDTO = try XCTUnwrap(readDTO(cid: cid, userId: userId))
+
+ // Assert pre-calculated values are overridden by argument values
+ XCTAssertNotEqual(readDTO.lastReadAt.bridgeDate, firstMessageDate)
+ XCTAssertNotEqual(readDTO.unreadMessageCount, 3)
+
+ // Assert passed values take precedence
+ XCTAssertNearlySameDate(readDTO.lastReadAt.bridgeDate, passedLastReadAt)
+ XCTAssertEqual(readDTO.unreadMessageCount, Int32(passedUnreadMessagesCount))
+ XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId)
+ }
// MARK: - markChannelAsUnread - whole channel
diff --git a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
index 6444c94a8c..8b0d374c63 100644
--- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
+++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
@@ -2855,7 +2855,7 @@ final class MessageDTO_Tests: XCTestCase {
let cid = ChannelId.unique
let createdAtFrom = Date()
- let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, context: database.viewContext)
+ let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, excludingMessageId: nil, context: database.viewContext)
XCTAssertEqual(count, 0)
}
@@ -2879,7 +2879,7 @@ final class MessageDTO_Tests: XCTestCase {
}
}
- let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, context: database.viewContext)
+ let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, excludingMessageId: nil, context: database.viewContext)
XCTAssertEqual(count, 0)
}
@@ -2912,7 +2912,7 @@ final class MessageDTO_Tests: XCTestCase {
}
}
- let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, context: database.viewContext)
+ let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, excludingMessageId: nil, context: database.viewContext)
XCTAssertEqual(count, 2)
}
@@ -2960,7 +2960,7 @@ final class MessageDTO_Tests: XCTestCase {
)
}
- let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, context: database.viewContext)
+ let count = MessageDTO.countOtherUserMessages(in: cid.rawValue, createdAtFrom: createdAtFrom, excludingMessageId: nil, context: database.viewContext)
XCTAssertEqual(count, 2)
}
diff --git a/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift
index 564a993ba4..3923e7bf5c 100644
--- a/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift
@@ -122,7 +122,7 @@ final class ChannelRepository_Tests: XCTestCase {
let expectation = self.expectation(description: "markUnread completes")
var receivedError: Error?
- repository.markUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique) { result in
+ repository.markUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -130,7 +130,7 @@ final class ChannelRepository_Tests: XCTestCase {
apiClient.test_simulateResponse(Result.success(.init()))
waitForExpectations(timeout: defaultTimeout)
- let referenceEndpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId)
+ let referenceEndpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId))
XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint))
XCTAssertEqual(database.writeSessionCounter, 1)
XCTAssertNil(receivedError)
@@ -143,7 +143,7 @@ final class ChannelRepository_Tests: XCTestCase {
let expectation = self.expectation(description: "markUnread completes")
var receivedError: Error?
- repository.markUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique) { result in
+ repository.markUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -153,7 +153,7 @@ final class ChannelRepository_Tests: XCTestCase {
waitForExpectations(timeout: defaultTimeout)
- let referenceEndpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId)
+ let referenceEndpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId))
XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint))
XCTAssertEqual(database.writeSessionCounter, 0)
XCTAssertEqual(receivedError, error)
diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
index 64be1a7a50..00f316e477 100644
--- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
@@ -437,7 +437,37 @@ final class MessageRepositoryTests: XCTestCase {
)
}
let result = try waitFor { done in
- repository.getMessage(before: "3", in: cid, completion: done)
+ repository.getMessage(before: .messageId("3"), in: cid, completion: done)
+ }
+ switch result {
+ case .success(let messageId):
+ XCTAssertEqual("2", messageId)
+ case .failure(let error):
+ XCTFail(error.localizedDescription)
+ }
+ }
+
+ func test_getMessageBefore_returnsCorrectResult_messageTimestamp() throws {
+ let cid = ChannelId.unique
+ try database.createCurrentUser()
+ try database.writeSynchronously { session in
+ let messages = (0..<5).map { index in
+ MessagePayload.dummy(
+ messageId: "\(index)",
+ createdAt: Date(timeIntervalSinceReferenceDate: TimeInterval(index))
+ )
+ }
+ try session.saveChannel(
+ payload: ChannelPayload.dummy(
+ channel: .dummy(cid: cid),
+ messages: messages
+ )
+ )
+ }
+ // Use a timestamp between message "2" and "3" to get message "2"
+ let timestamp = Date(timeIntervalSinceReferenceDate: 2.5)
+ let result = try waitFor { done in
+ repository.getMessage(before: .messageTimestamp(timestamp), in: cid, completion: done)
}
switch result {
case .success(let messageId):
diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
index 868283418c..d160a0fc71 100644
--- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
+++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift
@@ -1308,6 +1308,44 @@ final class Chat_Tests: XCTestCase {
await XCTAssertEqual(3, chat.state.channel?.reads.first?.unreadMessagesCount)
}
+ func test_markUnread_whenAPIRequestSucceeds_thenReadStateUpdates_messageTimestamp() async throws {
+ try await setUpChat(
+ usesMockedUpdaters: false,
+ messageCount: 3
+ )
+ let messages = await chat.state.messages
+ let firstMessage = try XCTUnwrap(messages.first)
+ let lastMessage = try XCTUnwrap(messages.first)
+
+ // Create a read state for the current user
+ try await env.client.databaseContainer.write { session in
+ let payload = ChannelPayload.dummy(
+ channel: .dummy(
+ cid: self.channelId,
+ lastMessageAt: lastMessage.createdAt
+ ),
+ channelReads: [
+ ChannelReadPayload(
+ user: .dummy(userId: self.currentUserId),
+ lastReadAt: lastMessage.createdAt,
+ lastReadMessageId: nil,
+ unreadMessagesCount: 0,
+ lastDeliveredAt: nil,
+ lastDeliveredMessageId: nil
+ )
+ ]
+ )
+ try session.saveChannel(payload: payload)
+ }
+
+ env.client.mockAPIClient.test_mockResponseResult(.success(EmptyResponse()))
+ try await chat.markUnread(from: firstMessage.createdAt)
+ XCTAssertNotNil(env.client.mockAPIClient.request_endpoint)
+
+ await XCTAssertEqual(1, chat.state.channel?.reads.count)
+ await XCTAssertEqual(2, chat.state.channel?.reads.first?.unreadMessagesCount)
+ }
+
// MARK: - Updating the Channel
func test_update_whenChannelUpdaterSucceeds_thenUpdateSucceeds() async throws {
diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift
index 4f533cc0c1..e26b1f43be 100644
--- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift
+++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift
@@ -1730,11 +1730,11 @@ final class ChannelUpdater_Tests: XCTestCase {
let messageId = MessageId.unique
let lastReadMessageId = MessageId.unique
- channelUpdater.markUnread(cid: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId)
+ channelUpdater.markUnread(cid: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId)
XCTAssertEqual(channelRepository.markUnreadCid, cid)
XCTAssertEqual(channelRepository.markUnreadUserId, userId)
- XCTAssertEqual(channelRepository.markUnreadMessageId, messageId)
+ XCTAssertEqual(channelRepository.markUnreadCriteria, .messageId(messageId))
XCTAssertEqual(channelRepository.markUnreadLastReadMessageId, lastReadMessageId)
}
@@ -1743,7 +1743,7 @@ final class ChannelUpdater_Tests: XCTestCase {
var receivedError: Error?
channelRepository.markUnreadResult = .success(.mock(cid: .unique))
- channelUpdater.markUnread(cid: .unique, userId: .unique, from: .unique, lastReadMessageId: .unique) { result in
+ channelUpdater.markUnread(cid: .unique, userId: .unique, from: .messageId(.unique), lastReadMessageId: .unique) { result in
receivedError = result.error
expectation.fulfill()
}
@@ -1758,7 +1758,7 @@ final class ChannelUpdater_Tests: XCTestCase {
var receivedError: Error?
channelRepository.markUnreadResult = .failure(mockedError)
- channelUpdater.markUnread(cid: .unique, userId: .unique, from: .unique, lastReadMessageId: .unique) { result in
+ channelUpdater.markUnread(cid: .unique, userId: .unique, from: .messageId(.unique), lastReadMessageId: .unique) { result in
receivedError = result.error
expectation.fulfill()
}
From 8a2bbb49b86866dd71c8e3e79ac5101b0f32f9cc Mon Sep 17 00:00:00 2001
From: Toomas Vahter
Date: Tue, 2 Dec 2025 13:19:49 +0200
Subject: [PATCH 8/9] Fix rare crash in WebSocketClient when updating the
engine (#3888)
---
Sources/StreamChat/WebSocketClient/WebSocketClient.swift | 4 +++-
.../SpyPattern/Spy/RequestEncoder_Spy.swift | 8 ++++----
.../WebSocketClient/WebSocketClient_Tests.swift | 9 +++++++++
3 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
index 9c162d311d..bb0f7bcce2 100644
--- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
+++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift
@@ -121,7 +121,9 @@ class WebSocketClient {
}
do {
- engine = try createEngineIfNeeded(for: endpoint)
+ try engineQueue.sync {
+ self.engine = try createEngineIfNeeded(for: endpoint)
+ }
} catch {
return
}
diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/RequestEncoder_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/RequestEncoder_Spy.swift
index 44e67f1920..54c6d50b29 100644
--- a/TestTools/StreamChatTestTools/SpyPattern/Spy/RequestEncoder_Spy.swift
+++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/RequestEncoder_Spy.swift
@@ -13,10 +13,10 @@ final class RequestEncoder_Spy: RequestEncoder, Spy {
weak var connectionDetailsProviderDelegate: ConnectionDetailsProviderDelegate?
- var encodeRequest: Result? = .success(URLRequest(url: .unique()))
- var onEncodeRequestCall: (() -> Void)?
- var encodeRequest_endpoints: [AnyEndpoint] = []
- var encodeRequest_completion: ((Result) -> Void)?
+ @Atomic var encodeRequest: Result? = .success(URLRequest(url: .unique()))
+ @Atomic var onEncodeRequestCall: (() -> Void)?
+ @Atomic var encodeRequest_endpoints: [AnyEndpoint] = []
+ @Atomic var encodeRequest_completion: ((Result) -> Void)?
func encodeRequest(
for endpoint: Endpoint,
diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
index bfe6d78bc3..55d619aa2c 100644
--- a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
+++ b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift
@@ -378,6 +378,15 @@ final class WebSocketClient_Tests: XCTestCase {
self.webSocketClient.connectEndpoint = .webSocketConnect(userInfo: UserInfo(id: "user_\(index)"))
})
}
+
+ func test_recreatingEngineConcurrently() {
+ DispatchQueue.concurrentPerform(iterations: 100, execute: { _ in
+ self.webSocketClient.initialize()
+ // Change mocked request so that connect recreates the engine
+ requestEncoder.encodeRequest = .success(.init(url: .unique()))
+ self.webSocketClient.connect()
+ })
+ }
// MARK: - Event handling tests
From e320a85eeefb95f5402b1c2f31264e8d519cddc1 Mon Sep 17 00:00:00 2001
From: Stream Bot
Date: Tue, 2 Dec 2025 12:18:00 +0000
Subject: [PATCH 9/9] Bump 4.94.0
---
CHANGELOG.md | 5 +++++
README.md | 2 +-
Sources/StreamChat/Generated/SystemEnvironment+Version.swift | 2 +-
Sources/StreamChat/Info.plist | 2 +-
Sources/StreamChatUI/Info.plist | 2 +-
StreamChat-XCFramework.podspec | 2 +-
StreamChat.podspec | 2 +-
StreamChatArtifacts.json | 2 +-
StreamChatUI-XCFramework.podspec | 2 +-
StreamChatUI.podspec | 2 +-
10 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 404558a9f3..cf21d26c8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming
+### 🔄 Changed
+
+# [4.94.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.94.0)
+_December 02, 2025_
+
## StreamChat
### ✅ Added
- Add `ChatClient.uploadAttachment(localUrl:progress:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
diff --git a/README.md b/README.md
index f739b83548..cf2716342a 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
index 3e66ea65c4..9ba83a95fb 100644
--- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
+++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
@@ -7,5 +7,5 @@ import Foundation
extension SystemEnvironment {
/// A Stream Chat version.
- public static let version: String = "4.94.0-SNAPSHOT"
+ public static let version: String = "4.94.0"
}
diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist
index a7bad28254..0665870af8 100644
--- a/Sources/StreamChat/Info.plist
+++ b/Sources/StreamChat/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 4.93.0
+ 4.94.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist
index a7bad28254..0665870af8 100644
--- a/Sources/StreamChatUI/Info.plist
+++ b/Sources/StreamChatUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 4.93.0
+ 4.94.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec
index ca004cff99..7682aea8a9 100644
--- a/StreamChat-XCFramework.podspec
+++ b/StreamChat-XCFramework.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'StreamChat-XCFramework'
- spec.version = '4.93.0'
+ spec.version = '4.94.0'
spec.summary = 'StreamChat iOS Client'
spec.description = 'stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications.'
diff --git a/StreamChat.podspec b/StreamChat.podspec
index 5769b60ab4..789b364d27 100644
--- a/StreamChat.podspec
+++ b/StreamChat.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'StreamChat'
- spec.version = '4.93.0'
+ spec.version = '4.94.0'
spec.summary = 'StreamChat iOS Chat Client'
spec.description = 'stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications.'
diff --git a/StreamChatArtifacts.json b/StreamChatArtifacts.json
index f56eb4699a..45a20daa5b 100644
--- a/StreamChatArtifacts.json
+++ b/StreamChatArtifacts.json
@@ -1 +1 @@
-{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip","4.81.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.81.0/StreamChat-All.zip","4.82.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.82.0/StreamChat-All.zip","4.83.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.83.0/StreamChat-All.zip","4.84.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.84.0/StreamChat-All.zip","4.85.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.85.0/StreamChat-All.zip","4.86.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.86.0/StreamChat-All.zip","4.87.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.87.0/StreamChat-All.zip","4.88.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.88.0/StreamChat-All.zip","4.89.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.89.0/StreamChat-All.zip","4.90.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.90.0/StreamChat-All.zip","4.91.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.91.0/StreamChat-All.zip","4.92.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.92.0/StreamChat-All.zip","4.93.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.93.0/StreamChat-All.zip"}
\ No newline at end of file
+{"4.7.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.7.0/StreamChat-All.zip","4.8.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.8.0/StreamChat-All.zip","4.9.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.9.0/StreamChat-All.zip","4.10.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.0/StreamChat-All.zip","4.10.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.10.1/StreamChat-All.zip","4.11.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.11.0/StreamChat-All.zip","4.12.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.12.0/StreamChat-All.zip","4.13.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.0/StreamChat-All.zip","4.13.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.13.1/StreamChat-All.zip","4.14.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.14.0/StreamChat-All.zip","4.15.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.0/StreamChat-All.zip","4.15.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.15.1/StreamChat-All.zip","4.16.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.16.0/StreamChat-All.zip","4.17.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.17.0/StreamChat-All.zip","4.18.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.18.0/StreamChat-All.zip","4.19.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.19.0/StreamChat-All.zip","4.20.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.20.0/StreamChat-All.zip","4.21.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.0/StreamChat-All.zip","4.21.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.1/StreamChat-All.zip","4.21.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.21.2/StreamChat-All.zip","4.22.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.22.0/StreamChat-All.zip","4.23.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.23.0/StreamChat-All.zip","4.24.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.0/StreamChat-All.zip","4.24.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.24.1/StreamChat-All.zip","4.25.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.0/StreamChat-All.zip","4.25.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.25.1/StreamChat-All.zip","4.26.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.26.0/StreamChat-All.zip","4.27.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.0/StreamChat-All.zip","4.27.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.27.1/StreamChat-All.zip","4.28.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.28.0/StreamChat-All.zip","4.29.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.29.0/StreamChat-All.zip","4.30.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.30.0/StreamChat-All.zip","4.31.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.31.0/StreamChat-All.zip","4.32.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.32.0/StreamChat-All.zip","4.33.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.33.0/StreamChat-All.zip","4.34.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.34.0/StreamChat-All.zip","4.35.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.0/StreamChat-All.zip","4.35.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.1/StreamChat-All.zip","4.35.2":"https://github.com/GetStream/stream-chat-swift/releases/download/4.35.2/StreamChat-All.zip","4.36.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.36.0/StreamChat-All.zip","4.37.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.0/StreamChat-All.zip","4.37.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.37.1/StreamChat-All.zip","4.38.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.38.0/StreamChat-All.zip","4.39.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.39.0/StreamChat-All.zip","4.40.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.40.0/StreamChat-All.zip","4.41.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.41.0/StreamChat-All.zip","4.42.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.42.0/StreamChat-All.zip","4.43.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.43.0/StreamChat-All.zip","4.44.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.44.0/StreamChat-All.zip","4.45.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.45.0/StreamChat-All.zip","4.46.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.46.0/StreamChat-All.zip","4.47.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.0/StreamChat-All.zip","4.47.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.47.1/StreamChat-All.zip","4.48.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.0/StreamChat-All.zip","4.48.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.48.1/StreamChat-All.zip","4.49.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.49.0/StreamChat-All.zip","4.50.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.50.0/StreamChat-All.zip","4.51.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.51.0/StreamChat-All.zip","4.52.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.52.0/StreamChat-All.zip","4.53.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.53.0/StreamChat-All.zip","4.54.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.54.0/StreamChat-All.zip","4.55.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.55.0/StreamChat-All.zip","4.56.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.0/StreamChat-All.zip","4.56.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.56.1/StreamChat-All.zip","4.57.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.57.0/StreamChat-All.zip","4.58.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.58.0/StreamChat-All.zip","4.59.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.59.0/StreamChat-All.zip","4.60.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.60.0/StreamChat-All.zip","4.61.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.61.0/StreamChat-All.zip","4.62.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.62.0/StreamChat-All.zip","4.63.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.63.0/StreamChat-All.zip","4.64.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.64.0/StreamChat-All.zip","4.65.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.65.0/StreamChat-All.zip","4.66.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.66.0/StreamChat-All.zip","4.67.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.67.0/StreamChat-All.zip","4.68.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.68.0/StreamChat-All.zip","4.69.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.69.0/StreamChat-All.zip","4.70.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.70.0/StreamChat-All.zip","4.71.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.71.0/StreamChat-All.zip","4.72.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.72.0/StreamChat-All.zip","4.73.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.73.0/StreamChat-All.zip","4.74.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.74.0/StreamChat-All.zip","4.75.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.75.0/StreamChat-All.zip","4.76.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.76.0/StreamChat-All.zip","4.77.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.77.0/StreamChat-All.zip","4.78.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.78.0/StreamChat-All.zip","4.79.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.0/StreamChat-All.zip","4.79.1":"https://github.com/GetStream/stream-chat-swift/releases/download/4.79.1/StreamChat-All.zip","4.80.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.80.0/StreamChat-All.zip","4.81.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.81.0/StreamChat-All.zip","4.82.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.82.0/StreamChat-All.zip","4.83.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.83.0/StreamChat-All.zip","4.84.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.84.0/StreamChat-All.zip","4.85.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.85.0/StreamChat-All.zip","4.86.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.86.0/StreamChat-All.zip","4.87.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.87.0/StreamChat-All.zip","4.88.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.88.0/StreamChat-All.zip","4.89.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.89.0/StreamChat-All.zip","4.90.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.90.0/StreamChat-All.zip","4.91.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.91.0/StreamChat-All.zip","4.92.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.92.0/StreamChat-All.zip","4.93.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.93.0/StreamChat-All.zip","4.94.0":"https://github.com/GetStream/stream-chat-swift/releases/download/4.94.0/StreamChat-All.zip"}
\ No newline at end of file
diff --git a/StreamChatUI-XCFramework.podspec b/StreamChatUI-XCFramework.podspec
index 435d14e6f1..ac7c9a802f 100644
--- a/StreamChatUI-XCFramework.podspec
+++ b/StreamChatUI-XCFramework.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'StreamChatUI-XCFramework'
- spec.version = '4.93.0'
+ spec.version = '4.94.0'
spec.summary = 'StreamChat UI Components'
spec.description = 'StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK.'
diff --git a/StreamChatUI.podspec b/StreamChatUI.podspec
index 5bad063de7..ad84060d68 100644
--- a/StreamChatUI.podspec
+++ b/StreamChatUI.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = "StreamChatUI"
- spec.version = "4.93.0"
+ spec.version = "4.94.0"
spec.summary = "StreamChat UI Components"
spec.description = "StreamChatUI SDK offers flexible UI components able to display data provided by StreamChat SDK."