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 @@

- StreamChat + StreamChat StreamChatUI

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."