diff --git a/CHANGELOG.md b/CHANGELOG.md index d3426ba2a5f..57b28e716ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [4.87.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.87.0) +_August 29, 2025_ + +## StreamChat +### ✅ Added +- Add support for `user.messages.deleted` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792) +- Add upload endpoint for standalone attachments [#3788](https://github.com/GetStream/stream-chat-swift/pull/3788) +- Add option to access the total message count of a channel [#3796](https://github.com/GetStream/stream-chat-swift/pull/3796) +### 🐞 Fixed +- Fix channel getting removed from channel list which includes blocked channels [#3794](https://github.com/GetStream/stream-chat-swift/pull/3794) +- Fix system messages not incrementing and decrementing unread counts [#3795](https://github.com/GetStream/stream-chat-swift/pull/3795) + +## StreamChatUI +### 🐞 Fixed +- Fix input text view's placeholder alignment in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790) +- Flip directional icons in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790) +- Fix swipe to reply gesture in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790) +### 🔄 Changed +- Use chevron icons in `ChatMessageAttachmentPreviewVC` [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790) + # [4.86.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.86.0) _August 21, 2025_ diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift index 9528dfdbbfd..230c27bf41d 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift @@ -48,6 +48,16 @@ final class DemoChatChannelListVC: ChatChannelListVC { .equal(.blocked, to: true) ]) ])) + + lazy var blockedUnblockedWithHiddenChannelsQuery: ChannelListQuery = .init( + filter: .and([ + .containMembers(userIds: [currentUserId]), + .or([ + .and([.equal(.blocked, to: false), .equal(.hidden, to: false)]), + .and([.equal(.blocked, to: true), .equal(.hidden, to: true)]) + ]) + ]) + ) lazy var unreadCountChannelsQuery: ChannelListQuery = .init( filter: .and([ @@ -161,6 +171,15 @@ final class DemoChatChannelListVC: ChatChannelListVC { self?.setAllBlockedChannelsQuery() } ) + + let blockedUnBlockedExcludingDeletedChannelsAction = UIAlertAction( + title: "Blocked Unblocked with Matching Hidden Channels", + style: .default, + handler: { [weak self] _ in + self?.title = "Blocked Unblocked with Matching Hidden Channels" + self?.setBlockedUnblockedWithHiddenChannelsQuery() + } + ) let unreadCountChannelsAction = UIAlertAction( title: "Unread Count Channels", @@ -230,6 +249,7 @@ final class DemoChatChannelListVC: ChatChannelListVC { hasUnreadChannelsAction, hiddenChannelsAction, allBlockedChannelsAction, + blockedUnBlockedExcludingDeletedChannelsAction, mutedChannelsAction, coolChannelsAction, pinnedChannelsAction, @@ -248,6 +268,10 @@ final class DemoChatChannelListVC: ChatChannelListVC { func setAllBlockedChannelsQuery() { replaceQuery(allBlockedChannelsQuery) } + + func setBlockedUnblockedWithHiddenChannelsQuery() { + replaceQuery(blockedUnblockedWithHiddenChannelsQuery) + } func setUnreadCountChannelsQuery() { replaceQuery(unreadCountChannelsQuery) diff --git a/README.md b/README.md index 64b1851b102..0e9dac39836 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

- StreamChat + StreamChat StreamChatUI

diff --git a/Sources/StreamChat/APIClient/AttachmentUploader/AttachmentUploader.swift b/Sources/StreamChat/APIClient/AttachmentUploader/AttachmentUploader.swift index 33ae7ef0832..d8e152d4b46 100644 --- a/Sources/StreamChat/APIClient/AttachmentUploader/AttachmentUploader.swift +++ b/Sources/StreamChat/APIClient/AttachmentUploader/AttachmentUploader.swift @@ -16,6 +16,17 @@ public protocol AttachmentUploader { progress: ((Double) -> Void)?, completion: @escaping (Result) -> Void ) + + /// Uploads a standalone attachment (not tied to message or channel), and returns the attachment with the remote information. + /// - Parameters: + /// - attachment: A standalone attachment. + /// - progress: The progress of the upload. + /// - completion: The callback with the uploaded attachment. + func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) } public class StreamAttachmentUploader: AttachmentUploader { @@ -41,4 +52,16 @@ public class StreamAttachmentUploader: AttachmentUploader { }) } } + + public func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) { + cdnClient.uploadStandaloneAttachment( + attachment, + progress: progress, + completion: completion + ) + } } diff --git a/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift b/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift index facef3c0f28..3c84ccbf124 100644 --- a/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift +++ b/Sources/StreamChat/APIClient/CDNClient/CDNClient.swift @@ -40,6 +40,17 @@ public protocol CDNClient { progress: ((Double) -> Void)?, completion: @escaping (Result) -> Void ) + + /// Uploads standalone attachment as a multipart/form-data and returns the uploaded remote file and its thumbnail. + /// - Parameters: + /// - attachment: An attachment to upload. + /// - progress: A closure that broadcasts upload progress. + /// - completion: Returns the uploaded file's information. + func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) } public extension CDNClient { @@ -104,13 +115,52 @@ class StreamCDNClient: CDNClient { let fileData = try? Data(contentsOf: uploadingState.localFileURL) else { return completion(.failure(ClientError.AttachmentUploading(id: attachment.id))) } + let endpoint = Endpoint.uploadAttachment(with: attachment.id.cid, type: attachment.type) + + uploadAttachment( + endpoint: endpoint, + fileData: fileData, + uploadingState: uploadingState, + progress: progress, + completion: completion + ) + } + + func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)? = nil, + completion: @escaping (Result) -> Void + ) { + guard + let uploadingState = attachment.uploadingState, + let fileData = try? Data(contentsOf: uploadingState.localFileURL) else { + return completion(.failure(ClientError.Unknown())) + } + + let endpoint = Endpoint.uploadAttachment(type: attachment.type) + + uploadAttachment( + endpoint: endpoint, + fileData: fileData, + uploadingState: uploadingState, + progress: progress, + completion: completion + ) + } + + private func uploadAttachment( + endpoint: Endpoint, + fileData: Data, + uploadingState: AttachmentUploadingState, + progress: ((Double) -> Void)? = nil, + completion: @escaping (Result) -> Void + ) { // Encode locally stored attachment into multipart form data let multipartFormData = MultipartFormData( fileData, fileName: uploadingState.localFileURL.lastPathComponent, mimeType: uploadingState.file.type.mimeType ) - let endpoint = Endpoint.uploadAttachment(with: attachment.id.cid, type: attachment.type) encoder.encodeRequest(for: endpoint) { [weak self] (requestResult) in var urlRequest: URLRequest diff --git a/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift index 383d446c07f..ff770298acb 100644 --- a/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/AttachmentEndpoints.swift @@ -7,7 +7,17 @@ import Foundation extension Endpoint { static func uploadAttachment(with cid: ChannelId, type: AttachmentType) -> Endpoint { .init( - path: .uploadAttachment(channelId: cid.apiPath, type: type == .image ? "image" : "file"), + path: .uploadChannelAttachment(channelId: cid.apiPath, type: type == .image ? "image" : "file"), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + } + + static func uploadAttachment(type: AttachmentType) -> Endpoint { + .init( + path: .uploadAttachment(type == .image ? "image" : "file"), method: .post, queryItems: nil, requiresConnectionId: false, diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index fc60e82be57..6751427b209 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -11,11 +11,11 @@ extension EndpointPath { return true case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel, .deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread, - .markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadAttachment, .message, + .markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message, .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, - .unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder, .liveLocations: + .unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder, .liveLocations, .uploadAttachment: return false } } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 708790dda51..c607d55237d 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -36,7 +36,8 @@ enum EndpointPath: Codable { case channelEvent(String) case stopWatchingChannel(String) case pinnedMessages(String) - case uploadAttachment(channelId: String, type: String) + case uploadChannelAttachment(channelId: String, type: String) + case uploadAttachment(String) case sendMessage(ChannelId) case message(MessageId) @@ -125,7 +126,8 @@ enum EndpointPath: Codable { case let .channelEvent(channelId): return "channels/\(channelId)/event" case let .stopWatchingChannel(channelId): return "channels/\(channelId)/stop-watching" case let .pinnedMessages(channelId): return "channels/\(channelId)/pinned_messages" - case let .uploadAttachment(channelId, type): return "channels/\(channelId)/\(type)" + case let .uploadChannelAttachment(channelId, type): return "channels/\(channelId)/\(type)" + case let .uploadAttachment(type): return "uploads/\(type)" case let .sendMessage(channelId): return "channels/\(channelId.apiPath)/message" case let .message(messageId): return "messages/\(messageId)" diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift index 8a05eee0ee1..55d57d980a4 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelCodingKeys.swift @@ -45,6 +45,7 @@ public enum ChannelCodingKeys: String, CodingKey, CaseIterable { /// The team the channel belongs to. case team case memberCount = "member_count" + case messageCount = "message_count" /// Cooldown duration for the channel, if it's in slow mode. /// This value will be 0 if the channel is not in slow mode. case cooldownDuration = "cooldown" diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index e9cc7d91614..77994fd3d6e 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -142,6 +142,8 @@ struct ChannelDetailPayload { let members: [MemberPayload]? let memberCount: Int + + let messageCount: Int? /// A list of users to invite in the channel. let invitedMembers: [MemberPayload] = [] // TODO? @@ -192,6 +194,7 @@ extension ChannelDetailPayload: Decodable { isHidden: try container.decodeIfPresent(Bool.self, forKey: .hidden), members: try container.decodeArrayIfPresentIgnoringFailures([MemberPayload].self, forKey: .members), memberCount: try container.decodeIfPresent(Int.self, forKey: .memberCount) ?? 0, + messageCount: try container.decodeIfPresent(Int.self, forKey: .messageCount), team: try container.decodeIfPresent(String.self, forKey: .team), cooldownDuration: try container.decodeIfPresent(Int.self, forKey: .cooldownDuration) ?? 0 ) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index d85da2ab1e4..00fd91e1e56 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -623,6 +623,26 @@ public class ChatClient { loadAppSettings { continuation.resume(with: $0) } } } + + // MARK: - Upload attachments + + /// Uploads an attachment to the specified CDN. + /// + /// - Parameters: + /// - attachment: the attachment to be uploaded. + /// - progress: the progress of the upload. + /// - completion: called when the attachment is uploaded. + public func upload( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) { + apiClient.attachmentUploader.uploadStandaloneAttachment( + attachment, + progress: progress, + completion: completion + ) + } // MARK: - Internal diff --git a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift index 0f6de2b45c3..3369ef01871 100644 --- a/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/LivestreamChannelController.swift @@ -790,11 +790,20 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel // MARK: - EventsControllerDelegate public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { - guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else { - return + if let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid { + handleChannelEvent(event) } - handleChannelEvent(event) + // User deleted messages event is a global event, not tied to a channel. + if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent { + let userId = userMessagesDeletedEvent.user.id + if userMessagesDeletedEvent.hardDelete { + hardDeleteMessages(from: userId) + } else { + let deletedAt = userMessagesDeletedEvent.createdAt + softDeleteMessages(from: userId, deletedAt: deletedAt) + } + } } // MARK: - AppStateObserverDelegate @@ -823,9 +832,6 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel paginationStateHandler.begin(pagination: pagination) } - let endpoint: Endpoint = - .updateChannel(query: channelQuery) - let requestCompletion: (Result) -> Void = { [weak self] result in self?.callback { [weak self] in guard let self = self else { return } @@ -1085,6 +1091,24 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel messages.removeAll { $0.id == deletedMessage.id } } + private func softDeleteMessages(from userId: UserId, deletedAt: Date) { + let messagesWithDeletedMessages = messages.map { message in + if message.author.id == userId { + return message.changing( + deletedAt: deletedAt + ) + } + return message + } + messages = messagesWithDeletedMessages + } + + private func hardDeleteMessages(from userId: UserId) { + messages.removeAll { message in + message.author.id == userId + } + } + private func handleNewReaction(_ reactionEvent: ReactionNewEvent) { updateMessage(reactionEvent.message) } diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index b0abf2be422..cbe0c05bd4a 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -41,6 +41,7 @@ class ChannelDTO: NSManagedObject { @NSManaged var watcherCount: Int64 @NSManaged var memberCount: Int64 + @NSManaged var messageCount: NSNumber? @NSManaged var isFrozen: Bool @NSManaged var cooldownDuration: Int @@ -261,6 +262,10 @@ extension NSManagedObjectContext { dto.defaultSortingAt = (payload.lastMessageAt ?? payload.createdAt).bridgeDate dto.lastMessageAt = payload.lastMessageAt?.bridgeDate dto.memberCount = Int64(clamping: payload.memberCount) + + if let messageCount = payload.messageCount { + dto.messageCount = NSNumber(value: messageCount) + } // Because `truncatedAt` is used, client side, for both truncation and channel hiding cases, we need to avoid using the // value returned by the Backend in some cases. @@ -285,8 +290,6 @@ extension NSManagedObjectContext { // for blocked 1:1 channels on channel list query if let isBlocked = payload.isBlocked { dto.isBlocked = isBlocked - } else { - dto.isBlocked = false } // Backend only returns a boolean for hidden state @@ -642,6 +645,7 @@ extension ChatChannel { unreadCount: unreadCount, watcherCount: Int(dto.watcherCount), memberCount: Int(dto.memberCount), + messageCount: dto.messageCount?.intValue, reads: reads, cooldownDuration: Int(dto.cooldownDuration), extraData: extraData, diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index f0c85f09220..3c9162da3ce 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -852,6 +852,10 @@ extension DatabaseSession { if isNewMessage && savedMessage.localMessageState != nil { savedMessage.markMessageAsSent() } + + if let messageCount = payload.channelMessageCount { + channelDTO.messageCount = NSNumber(value: messageCount) + } } func updateChannelPreview(from payload: EventPayload) { diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 17b35000091..e1f6b951516 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -49,12 +49,13 @@ - + + diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift index f1c2b21e543..384ecd81940 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.86.0" + public static let version: String = "4.87.0" } diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist index 10dcd1335d0..33653b5dee1 100644 --- a/Sources/StreamChat/Info.plist +++ b/Sources/StreamChat/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.86.0 + 4.87.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageAttachment.swift index d7baa0ce87a..9e5aac6c34d 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageAttachment.swift @@ -4,6 +4,39 @@ import Foundation +public struct StreamAttachment { + /// The attachment type. + public let type: AttachmentType + + /// The attachment payload. + public var payload: Payload + + /// The downloading state of the attachment. + /// + /// Reflects the downloading progress for attachments. + public let downloadingState: AttachmentDownloadingState? + + /// The uploading state of the attachment. + /// + /// Reflects uploading progress for local attachments that require file uploading. + /// Is `nil` for local attachments that don't need to be uploaded. + /// + /// Becomes `nil` when the message with the current attachment is sent. + public let uploadingState: AttachmentUploadingState? + + public init( + type: AttachmentType, + payload: Payload, + downloadingState: AttachmentDownloadingState?, + uploadingState: AttachmentUploadingState? + ) { + self.type = type + self.payload = payload + self.downloadingState = downloadingState + self.uploadingState = uploadingState + } +} + /// A type representing a chat message attachment. /// `ChatMessageAttachment` is an immutable snapshot of message attachment at the given time. @dynamicMemberLookup @@ -81,6 +114,13 @@ public struct AttachmentUploadingState: Hashable { /// The information about file size/mimeType. public let file: AttachmentFile + + /// Public init. + public init(localFileURL: URL, state: LocalAttachmentState, file: AttachmentFile) { + self.localFileURL = localFileURL + self.state = state + self.file = file + } } // MARK: - Type erasure/recovery diff --git a/Sources/StreamChat/Models/Channel.swift b/Sources/StreamChat/Models/Channel.swift index 6a0779c2f2a..aa0eedecb5b 100644 --- a/Sources/StreamChat/Models/Channel.swift +++ b/Sources/StreamChat/Models/Channel.swift @@ -61,6 +61,10 @@ public struct ChatChannel { /// The total number of members in the channel. public let memberCount: Int + + /// The total number of messages in the channel. + /// Only returns value if `count_messages` is configured for your app. + public let messageCount: Int? /// A list of members of this channel. /// @@ -197,6 +201,7 @@ public struct ChatChannel { unreadCount: ChannelUnreadCount, watcherCount: Int = 0, memberCount: Int = 0, + messageCount: Int? = nil, reads: [ChatChannelRead] = [], cooldownDuration: Int = 0, extraData: [String: RawJSON], @@ -227,6 +232,7 @@ public struct ChatChannel { self.team = team self.watcherCount = watcherCount self.memberCount = memberCount + self.messageCount = messageCount self.reads = reads self.cooldownDuration = cooldownDuration self.extraData = extraData diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 80edd933d4f..14711b8c644 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -554,6 +554,7 @@ extension ChatMessage: Hashable { guard lhs.id == rhs.id else { return false } guard lhs.localState == rhs.localState else { return false } guard lhs.updatedAt == rhs.updatedAt else { return false } + guard lhs.deletedAt == rhs.deletedAt else { return false } guard lhs.allAttachments == rhs.allAttachments else { return false } guard lhs.poll == rhs.poll else { return false } guard lhs.author == rhs.author else { return false } diff --git a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift index 8b251d40f19..f1348b83051 100644 --- a/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift +++ b/Sources/StreamChat/Models/Payload+asModel/ChannelPayload+asModel.swift @@ -53,6 +53,7 @@ extension ChannelPayload { unreadCount: unreadCount ?? .noUnread, watcherCount: watcherCount ?? 0, memberCount: channelPayload.memberCount, + messageCount: channelPayload.messageCount, reads: mappedReads, cooldownDuration: channelPayload.cooldownDuration, extraData: channelPayload.extraData, diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift index 471d6a5ac28..7b6cf562756 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift @@ -228,10 +228,6 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware { return .messageIsThreadReply } - if message.type == .system { - return .messageIsSystem - } - if message.isShadowed { return .messageIsShadowed } @@ -250,7 +246,6 @@ private enum UnreadSkippingReason: CustomStringConvertible { case messageIsOwn case messageIsSilent case messageIsThreadReply - case messageIsSystem case messageIsShadowed case messageIsSeen case messageIsSoftDeleted @@ -267,8 +262,6 @@ private enum UnreadSkippingReason: CustomStringConvertible { return "Silent messages do not affect unread counts" case .messageIsThreadReply: return "Thread replies do not affect unread counts" - case .messageIsSystem: - return "System messages do not affect unread counts" case .messageIsShadowed: return "Shadowed messages do not affect unread counts" case .messageIsSeen: diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift index 9ffdf9f4ce3..9f023e2636f 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/UserChannelBanEventsMiddleware.swift @@ -30,6 +30,18 @@ struct UserChannelBanEventsMiddleware: EventMiddleware { memberDTO.isShadowBanned = false memberDTO.banExpiresAt = nil + case let userMessagesDeletedEvent as UserMessagesDeletedEventDTO: + let userId = userMessagesDeletedEvent.user.id + if let userDTO = session.user(id: userId) { + userDTO.messages?.forEach { message in + if userMessagesDeletedEvent.payload.hardDelete { + message.isHardDeleted = true + } else { + message.deletedAt = userMessagesDeletedEvent.createdAt.bridgeDate + } + } + } + default: break } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 689d6e61952..786d7bcb184 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -41,6 +41,7 @@ class EventPayload: Decodable { case aiMessage = "ai_message" case draft case reminder + case channelMessageCount = "channel_message_count" } let eventType: EventType @@ -79,6 +80,7 @@ class EventPayload: Decodable { let aiMessage: String? let draft: DraftPayload? let reminder: ReminderPayload? + let channelMessageCount: Int? init( eventType: EventType, @@ -112,7 +114,8 @@ class EventPayload: Decodable { messageId: String? = nil, aiMessage: String? = nil, draft: DraftPayload? = nil, - reminder: ReminderPayload? = nil + reminder: ReminderPayload? = nil, + channelMessageCount: Int? = nil ) { self.eventType = eventType self.connectionId = connectionId @@ -146,6 +149,7 @@ class EventPayload: Decodable { self.aiMessage = aiMessage self.draft = draft self.reminder = reminder + self.channelMessageCount = channelMessageCount } required init(from decoder: Decoder) throws { @@ -184,6 +188,7 @@ class EventPayload: Decodable { aiMessage = try container.decodeIfPresent(String.self, forKey: .aiMessage) draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder) + channelMessageCount = try container.decodeIfPresent(Int.self, forKey: .channelMessageCount) } func event() throws -> Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index 504ef2e2502..b02d7f4bf37 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -38,6 +38,8 @@ public extension EventType { static let userBanned: Self = "user.banned" /// When a user was unbanned. static let userUnbanned: Self = "user.unbanned" + /// When the messages of a banned user should be deleted. + static let userMessagesDeleted: Self = "user.messages.deleted" // MARK: Channel Events @@ -191,6 +193,8 @@ extension EventType { return try (try? UserBannedEventDTO(from: response)) ?? UserGloballyBannedEventDTO(from: response) case .userUnbanned: return try (try? UserUnbannedEventDTO(from: response)) ?? UserGloballyUnbannedEventDTO(from: response) + case .userMessagesDeleted: + return try UserMessagesDeletedEventDTO(from: response) case .channelCreated: throw ClientError.IgnoredEventType() case .channelUpdated: return try ChannelUpdatedEventDTO(from: response) diff --git a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift index 0da70e117b9..ee17af1049a 100644 --- a/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/UserEvents.swift @@ -271,3 +271,44 @@ class UserUnbannedEventDTO: EventDTO { ) } } + +/// Triggered when the messages of a banned user should be deleted. +public struct UserMessagesDeletedEvent: Event { + /// The banned user. + public let user: ChatUser + + /// If the messages should be hard deleted or not. + public let hardDelete: Bool + + /// The event timestamp + public let createdAt: Date +} + +class UserMessagesDeletedEventDTO: EventDTO { + let user: UserPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + user = try response.value(at: \.user) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: DatabaseSession) -> Event? { + if let userDTO = session.user(id: user.id), + let userModel = try? userDTO.asModel() { + return UserMessagesDeletedEvent( + user: userModel, + hardDelete: payload.hardDelete, + createdAt: createdAt + ) + } + + return UserMessagesDeletedEvent( + user: user.asModel(), + hardDelete: payload.hardDelete, + createdAt: createdAt + ) + } +} diff --git a/Sources/StreamChat/Workers/UserUpdater.swift b/Sources/StreamChat/Workers/UserUpdater.swift index ad703fd8741..81c455306a4 100644 --- a/Sources/StreamChat/Workers/UserUpdater.swift +++ b/Sources/StreamChat/Workers/UserUpdater.swift @@ -44,6 +44,7 @@ class UserUpdater: Worker { participantId: userId, context: self.database.writableContext ) + channel?.isBlocked = true channel?.isHidden = true }, completion: { if let error = $0 { @@ -75,6 +76,7 @@ class UserUpdater: Worker { participantId: userId, context: self.database.writableContext ) + channel?.isBlocked = false channel?.isHidden = false }, completion: { if let error = $0 { diff --git a/Sources/StreamChatUI/Appearance+Images.swift b/Sources/StreamChatUI/Appearance+Images.swift index 065165575c6..0feaef3cb68 100644 --- a/Sources/StreamChatUI/Appearance+Images.swift +++ b/Sources/StreamChatUI/Appearance+Images.swift @@ -46,7 +46,7 @@ public extension Appearance { public var smallBolt: UIImage = loadImageSafely(with: "bolt_small") public var openAttachments: UIImage = loadImageSafely(with: "clip") public var shrinkInputArrow: UIImage = loadImageSafely(with: "arrow_shrink_input") - public var sendArrow: UIImage = loadImageSafely(with: "arrow_send") + public var sendArrow: UIImage = loadImageSafely(with: "arrow_send").imageFlippedForRightToLeftLayoutDirection() public var scrollDownArrow: UIImage = loadImageSafely(with: "arrow_down") public var whiteCheckmark: UIImage = loadImageSafely(with: "checkmark_white") public var confirmCheckmark: UIImage = loadImageSafely(with: "checkmark_confirm") @@ -61,14 +61,14 @@ public extension Appearance { public var mic: UIImage = loadSafely(systemName: "mic", assetsFallback: "mic") public var lock: UIImage = loadSafely(systemName: "lock", assetsFallback: "lock") - public var chevronLeft: UIImage = loadSafely(systemName: "chevron.left", assetsFallback: "chevron.left") - public var chevronRight: UIImage = loadSafely(systemName: "chevron.right", assetsFallback: "chevron.right") + public var chevronLeft: UIImage = loadSafely(systemName: "chevron.left", assetsFallback: "chevron.left").imageFlippedForRightToLeftLayoutDirection() + public var chevronRight: UIImage = loadSafely(systemName: "chevron.right", assetsFallback: "chevron.right").imageFlippedForRightToLeftLayoutDirection() public var chevronUp: UIImage = loadSafely(systemName: "chevron.up", assetsFallback: "chevron.up") public var trash: UIImage = loadSafely(systemName: "trash", assetsFallback: "trash") public var stop: UIImage = loadSafely(systemName: "stop.circle", assetsFallback: "") - public var playFill: UIImage = loadSafely(systemName: "play.fill", assetsFallback: "play.fill") + public var playFill: UIImage = loadSafely(systemName: "play.fill", assetsFallback: "play.fill").imageFlippedForRightToLeftLayoutDirection() public var pauseFill: UIImage = loadSafely(systemName: "pause.fill", assetsFallback: "pause.fill") - public var recordingPlay: UIImage = loadSafely(systemName: "play", assetsFallback: "play_big") + public var recordingPlay: UIImage = loadSafely(systemName: "play", assetsFallback: "play_big").imageFlippedForRightToLeftLayoutDirection() public var recordingPause: UIImage = loadSafely(systemName: "pause", assetsFallback: "pause.fill") public var rateButtonPillBackground: UIImage = loadImageSafely(with: "pill") public var sliderThumb: UIImage = loadImageSafely(with: "sliderThumb") @@ -228,8 +228,8 @@ public extension Appearance { // MARK: - Message Actions - public var messageActionSwipeReply: UIImage = loadImageSafely(with: "icn_inline_reply") - public var messageActionInlineReply: UIImage = loadImageSafely(with: "icn_inline_reply") + public var messageActionSwipeReply: UIImage = loadImageSafely(with: "icn_inline_reply").imageFlippedForRightToLeftLayoutDirection() + public var messageActionInlineReply: UIImage = loadImageSafely(with: "icn_inline_reply").imageFlippedForRightToLeftLayoutDirection() public var messageActionThreadReply: UIImage = loadImageSafely(with: "icn_thread_reply") public var messageActionMarkUnread: UIImage = loadSafely(systemName: "message.badge", assetsFallback: "mark_unread") @@ -285,9 +285,9 @@ public extension Appearance { } public var camera: UIImage = loadImageSafely(with: "camera") - public var bigPlay: UIImage = loadImageSafely(with: "play_big") + public var bigPlay: UIImage = loadImageSafely(with: "play_big").imageFlippedForRightToLeftLayoutDirection() - public var play: UIImage = loadImageSafely(with: "play") + public var play: UIImage = loadImageSafely(with: "play").imageFlippedForRightToLeftLayoutDirection() public var pause: UIImage = loadImageSafely(with: "pause") // MARK: - CommandIcons diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift index cf7cd9b69fa..8f243b3209e 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift @@ -28,14 +28,14 @@ open class ChatMessageAttachmentPreviewVC: _ViewController, WKNavigationDelegate ) private lazy var goBackButton = UIBarButtonItem( - title: "←", + image: appearance.images.chevronLeft, style: .plain, target: self, action: #selector(goBack) ) private lazy var goForwardButton = UIBarButtonItem( - title: "→", + image: appearance.images.chevronRight, style: .plain, target: self, action: #selector(goForward) diff --git a/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift b/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift index ca880ef1cf8..6962c75b6de 100644 --- a/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift +++ b/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift @@ -95,7 +95,11 @@ open class SwipeToReplyGestureHandler { let translation = gesture.translation(in: messageCell) animateViews(with: translation) - shouldReply = translation.x > swipeThreshold + if messageCell?.effectiveUserInterfaceLayoutDirection == .rightToLeft { + shouldReply = -translation.x > swipeThreshold + } else { + shouldReply = translation.x > swipeThreshold + } if shouldReply && shouldTriggerFeedback { impactFeedbackGenerator.impactOccurred() @@ -121,21 +125,23 @@ open class SwipeToReplyGestureHandler { /// Animates the views when a swiping is happening. open func animateViews(with translation: CGPoint) { + let isRTL = messageCell?.effectiveUserInterfaceLayoutDirection == .rightToLeft swipeableViews.forEach { guard let originalCenter = swipeableViewsOriginalPositions[$0] else { return } + let x = isRTL ? min(originalCenter.x, originalCenter.x + translation.x) : max(originalCenter.x, originalCenter.x + translation.x) $0.center = CGPoint( - x: max(originalCenter.x, originalCenter.x + translation.x), + x: x, y: originalCenter.y ) } - let replyIconTranslation = max(0, min(translation.x, swipeThreshold)) + let replyIconTranslation = isRTL ? min(0, max(translation.x, -swipeThreshold)) : max(0, min(translation.x, swipeThreshold)) replyIconImageView?.center = CGPoint( x: replyIconOriginalPosition.x + replyIconTranslation, y: replyIconOriginalPosition.y ) replyIconImageView?.isHidden = false - replyIconImageView?.alpha = replyIconTranslation / swipeThreshold + replyIconImageView?.alpha = abs(replyIconTranslation) / swipeThreshold } /// Animates the views to their original positions. diff --git a/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift b/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift index c4070f21f6c..979742a5a21 100644 --- a/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift +++ b/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift @@ -157,9 +157,11 @@ open class InputTextView: UITextView, ThemeProvider { open func setUpLayout() { addSubview(placeholderLabel) + placeholderLabel.setContentCompressionResistancePriority(.streamLow, for: .horizontal) NSLayoutConstraint.activate([ placeholderLabel.leadingAnchor.pin(equalTo: leadingAnchor, constant: directionalLayoutMargins.leading), - placeholderLabel.trailingAnchor.pin(lessThanOrEqualTo: trailingAnchor), + placeholderLabel.trailingAnchor.pin(equalTo: trailingAnchor, constant: -directionalLayoutMargins.trailing), + placeholderLabel.widthAnchor.pin(equalTo: layoutMarginsGuide.widthAnchor), placeholderLabel.topAnchor.pin(equalTo: topAnchor), placeholderLabel.bottomAnchor.pin(lessThanOrEqualTo: bottomAnchor), placeholderLabel.centerYAnchor.pin(equalTo: centerYAnchor) diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist index 10dcd1335d0..33653b5dee1 100644 --- a/Sources/StreamChatUI/Info.plist +++ b/Sources/StreamChatUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.86.0 + 4.87.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/StreamChatUI/VoiceRecording/Views/SlideToCancelView.swift b/Sources/StreamChatUI/VoiceRecording/Views/SlideToCancelView.swift index eab2f134cd1..0caffcf5ae8 100644 --- a/Sources/StreamChatUI/VoiceRecording/Views/SlideToCancelView.swift +++ b/Sources/StreamChatUI/VoiceRecording/Views/SlideToCancelView.swift @@ -60,27 +60,14 @@ open class SlideToCancelView: _View, ThemeProvider { override open func setUpAppearance() { super.setUpAppearance() - configureChevron() + chevronImageView.image = appearance.images.chevronLeft.tinted(with: appearance.colorPalette.textLowEmphasis) titleLabel.textColor = appearance.colorPalette.textLowEmphasis titleLabel.font = appearance.fonts.body titleLabel.text = L10n.Recording.slideToCancel } - - override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if traitCollection.layoutDirection != previousTraitCollection?.layoutDirection { - configureChevron() - } - } override open func updateContent() { super.updateContent() alpha = content.alpha } - - private func configureChevron() { - let chevron = traitCollection.layoutDirection == .leftToRight ? appearance.images.chevronLeft : appearance.images.chevronRight - chevronImageView.image = chevron.tinted(with: appearance.colorPalette.textLowEmphasis) - } } diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec index e7a50557a56..0b3caf503b7 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.86.0" + spec.version = "4.87.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 5508dd3a6fd..15f0bf95979 100644 --- a/StreamChat.podspec +++ b/StreamChat.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChat" - spec.version = "4.86.0" + spec.version = "4.87.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/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 6d939db3995..140ad99c899 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -797,6 +797,7 @@ 84B7383E2BE8C13A00EC66EC /* PollController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7383C2BE8C13A00EC66EC /* PollController+SwiftUI.swift */; }; 84B8779E2AC30F0E009EF76A /* DemoShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8779D2AC30F0E009EF76A /* DemoShareViewModel.swift */; }; 84B877A02AC31AB8009EF76A /* StreamChat+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8779F2AC31AB8009EF76A /* StreamChat+Extensions.swift */; }; + 84BC99282E5C56590000FB87 /* StreamAttachment_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */; }; 84BE85DA2AC30E88007DD47C /* DemoShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BE85D92AC30E88007DD47C /* DemoShareView.swift */; }; 84C11BDF27FB2B4600000A9E /* ChannelPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */; }; 84C11BE127FB2C2B00000A9E /* ChannelReadDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C11BE027FB2C2B00000A9E /* ChannelReadDTO_Tests.swift */; }; @@ -3793,6 +3794,7 @@ 84B7383C2BE8C13A00EC66EC /* PollController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollController+SwiftUI.swift"; sourceTree = ""; }; 84B8779D2AC30F0E009EF76A /* DemoShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoShareViewModel.swift; sourceTree = ""; }; 84B8779F2AC31AB8009EF76A /* StreamChat+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamChat+Extensions.swift"; sourceTree = ""; }; + 84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamAttachment_Mock.swift; sourceTree = ""; }; 84BE85D92AC30E88007DD47C /* DemoShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoShareView.swift; sourceTree = ""; }; 84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelPayload.swift; sourceTree = ""; }; 84C11BE027FB2C2B00000A9E /* ChannelReadDTO_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelReadDTO_Tests.swift; sourceTree = ""; }; @@ -7002,6 +7004,7 @@ A344075B27D753530044F150 /* ChatMessageLinkAttachment_Mock.swift */, 4F1FB7D72C7DEC6600C47C2A /* ChatMessageVideoAttachment_Mock.swift */, 40A2961929F8244500E0C186 /* ChatMessageVoiceRecordingAttachment_Mock.swift */, + 84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */, ); path = Attachments; sourceTree = ""; @@ -11393,6 +11396,7 @@ A311B42D27E8BB7400CFCF6D /* StreamChatTestTools.swift in Sources */, 40D484022A1264F1009E4134 /* MockAudioRecorder.swift in Sources */, 82F714A72B0784D900442A74 /* UnwrapAsync.swift in Sources */, + 84BC99282E5C56590000FB87 /* StreamAttachment_Mock.swift in Sources */, A344077627D753530044F150 /* ChatMessageReaction_Mock.swift in Sources */, A311B43B27E8BC8400CFCF6D /* ChannelWatcherListController_Delegate.swift in Sources */, 82E655332B06748400D64906 /* Spy.swift in Sources */, diff --git a/StreamChatArtifacts.json b/StreamChatArtifacts.json index 6251d52ba52..16b34cf6420 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"} \ 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"} \ No newline at end of file diff --git a/StreamChatUI-XCFramework.podspec b/StreamChatUI-XCFramework.podspec index 11ff3a581f7..8961481d2c2 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.86.0" + spec.version = "4.87.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 e77acefc812..5cd598f6e0c 100644 --- a/StreamChatUI.podspec +++ b/StreamChatUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "StreamChatUI" - spec.version = "4.86.0" + spec.version = "4.87.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/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift b/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift index 70e4ae01a15..cb73e5e7385 100644 --- a/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift +++ b/TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift @@ -28,7 +28,7 @@ extension EndpointPath: Equatable { case let (.channelEvent(string1), .channelEvent(string2)): return string1 == string2 case let (.stopWatchingChannel(string1), .stopWatchingChannel(string2)): return string1 == string2 case let (.pinnedMessages(string1), .pinnedMessages(string2)): return string1 == string2 - case let (.uploadAttachment(channelId1, type1), .uploadAttachment(channelId2, type2)): return channelId1 == channelId2 && + case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)): return channelId1 == channelId2 && type1 == type2 case let (.sendMessage(channelId1), .sendMessage(channelId2)): return channelId1 == channelId2 diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json index 708c04bcd8b..2c74ec1cf14 100644 --- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Channel.json @@ -1309,6 +1309,7 @@ "disabled" : true, "cooldown" : 10, "member_count" : 4, + "message_count" : 5, "updated_at" : "2019-05-10T14:03:49.505006Z", "config" : { "automod_behavior" : "flag", diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/StreamAttachment_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/StreamAttachment_Mock.swift new file mode 100644 index 00000000000..43b03abbebb --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/StreamAttachment_Mock.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +import Foundation + +public extension StreamAttachment { + /// Creates a new `ChatMessageFileAttachment` object from the provided data. + static func mock( + payload: Payload, + title: String = "Sample.png", + assetURL: URL = URL(string: "http://asset.url")!, + file: AttachmentFile = AttachmentFile(type: .png, size: 120, mimeType: "image/png"), + localState: LocalAttachmentState? = .uploaded, + localDownloadState: LocalAttachmentDownloadState? = nil, + uploadingState: AttachmentUploadingState? = nil, + extraData: [String: RawJSON]? = nil + ) -> Self { + .init( + type: .image, + payload: payload, + downloadingState: localDownloadState.map { + .init( + localFileURL: $0 == .downloaded ? .newTemporaryFileURL() : nil, + state: $0, + file: file + ) + }, + uploadingState: uploadingState ?? localState.map { + .init( + localFileURL: assetURL, + state: $0, + file: file + ) + } + ) + } +} diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/AttachmentUploader_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/AttachmentUploader_Spy.swift index 440f8cbb826..b1516ef48cb 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/AttachmentUploader_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/AttachmentUploader_Spy.swift @@ -28,4 +28,22 @@ final class AttachmentUploader_Spy: AttachmentUploader, Spy { } } } + + func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) { + record() + + if let uploadAttachmentProgress = uploadAttachmentProgress { + progress?(uploadAttachmentProgress) + } + + if let uploadAttachmentResult = uploadAttachmentResult { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + completion(uploadAttachmentResult.map { UploadedFile(fileURL: $0.remoteURL )}) + } + } + } } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift index c03fc7fb0b4..ce4481073ea 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/CDNClient_Spy.swift @@ -28,4 +28,21 @@ final class CDNClient_Spy: CDNClient, Spy { } } } + + func uploadStandaloneAttachment( + _ attachment: StreamAttachment, + progress: ((Double) -> Void)?, + completion: @escaping (Result) -> Void + ) { + record() + if let uploadAttachmentProgress = uploadAttachmentProgress { + progress?(uploadAttachmentProgress) + } + + if let uploadAttachmentResult = uploadAttachmentResult { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + completion(uploadAttachmentResult.map { UploadedFile(fileURL: $0) }) + } + } + } } diff --git a/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift b/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift index 07eefbdc8d4..ddf9d0d3a29 100644 --- a/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift +++ b/TestTools/StreamChatTestTools/TestData/CustomCDNClient.swift @@ -13,4 +13,10 @@ 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 + ) {} } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift index 6854c793297..9c3b763dfc8 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/ChannelDetailPayload.swift @@ -22,11 +22,12 @@ extension ChannelDetailPayload { config: ChannelConfig = .mock(), ownCapabilities: [String] = [], isFrozen: Bool = false, - isBlocked: Bool = false, + isBlocked: Bool? = false, isDisabled: Bool = false, isHidden: Bool? = nil, members: [MemberPayload] = [], memberCount: Int? = nil, + messageCount: Int? = nil, team: String? = nil, cooldownDuration: Int = 0 ) -> Self { @@ -50,6 +51,7 @@ extension ChannelDetailPayload { isHidden: isHidden, members: members, memberCount: memberCount ?? members.count, + messageCount: messageCount, team: team, cooldownDuration: cooldownDuration ) diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift index 9a4b1f5272f..2e18c4b784b 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/XCTestCase+Dummy.swift @@ -156,6 +156,8 @@ extension XCTestCase { ownCapabilities: [String] = [], channelExtraData: [String: RawJSON] = [:], createdAt: Date = XCTestCase.channelCreatedDate, + blocked: Bool? = false, + hidden: Bool? = nil, truncatedAt: Date? = nil, cooldownDuration: Int? = nil, channelReads: [ChannelReadPayload]? = nil @@ -189,10 +191,11 @@ extension XCTestCase { ownCapabilities: ownCapabilities, isDisabled: false, isFrozen: true, - isBlocked: false, - isHidden: nil, + isBlocked: blocked, + isHidden: hidden, members: members, memberCount: 100, + messageCount: 100, team: .unique, cooldownDuration: cooldownDuration ?? .random(in: 0...120) ), @@ -311,6 +314,7 @@ extension XCTestCase { isHidden: nil, members: nil, memberCount: 100, + messageCount: 100, team: .unique, cooldownDuration: .random(in: 0...120) ), @@ -409,9 +413,9 @@ extension XCTestCase { latestAnswers: [PollVotePayload?]? = nil, options: [PollOptionPayload?] = [], ownVotes: [PollVotePayload?] = [], - custom: [String : RawJSON] = [:], - latestVotesByOption: [String : [PollVotePayload]] = [:], - voteCountsByOption: [String : Int] = [:], + custom: [String: RawJSON] = [:], + latestVotesByOption: [String: [PollVotePayload]] = [:], + voteCountsByOption: [String: Int] = [:], isClosed: Bool? = nil, maxVotesAllowed: Int? = nil, votingVisibility: String? = nil, @@ -501,7 +505,7 @@ private extension MemberPayload { isInvisible: true, isBanned: true, teams: [], - language: nil, + language: nil, extraData: [:] ), userId: userId, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift index ad3cd16e6ba..344a4b9c6a8 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/AttachmentEndpoints_Tests.swift @@ -22,7 +22,7 @@ final class AttachmentEndpoints_Tests: XCTestCase { for (type, pathComponent) in testCases { let expectedEndpoint: Endpoint = .init( - path: .uploadAttachment(channelId: id.cid.apiPath, type: pathComponent), + path: .uploadChannelAttachment(channelId: id.cid.apiPath, type: pathComponent), method: .post, queryItems: nil, requiresConnectionId: false, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift index 5993c05233b..7d24475b095 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift @@ -121,7 +121,7 @@ final class EndpointPathTests: XCTestCase { assertResultEncodingAndDecoding(.channelEvent("channel_idq")) assertResultEncodingAndDecoding(.stopWatchingChannel("channel_idq")) assertResultEncodingAndDecoding(.pinnedMessages("channel_idq")) - assertResultEncodingAndDecoding(.uploadAttachment(channelId: "channel_id", type: "file")) + assertResultEncodingAndDecoding(.uploadChannelAttachment(channelId: "channel_id", type: "file")) assertResultEncodingAndDecoding(.sendMessage(ChannelId(type: .messaging, id: "the_id"))) assertResultEncodingAndDecoding(.message("message_idm")) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 060d1519239..d993de68aa9 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -163,6 +163,7 @@ final class ChannelListPayload_Tests: XCTestCase { ) }, memberCount: 100, + messageCount: 100, team: .unique, cooldownDuration: .random(in: 0...120) ) @@ -304,6 +305,7 @@ final class ChannelPayload_Tests: XCTestCase { XCTAssertEqual(channel.isDisabled, true) XCTAssertEqual(channel.isFrozen, true) XCTAssertEqual(channel.memberCount, 4) + XCTAssertEqual(channel.messageCount, 5) XCTAssertEqual(channel.updatedAt, "2019-05-10T14:03:49.505006Z".toDate()) XCTAssertEqual(channel.cooldownDuration, 10) XCTAssertEqual(channel.team, "GREEN") @@ -440,6 +442,7 @@ final class ChannelPayload_Tests: XCTestCase { isHidden: true, members: [memberPayload], memberCount: 10, + messageCount: 10, team: "team-id", cooldownDuration: 30 ) @@ -494,6 +497,7 @@ final class ChannelPayload_Tests: XCTestCase { XCTAssertEqual(chatChannel.unreadCount, unreadCount) XCTAssertEqual(chatChannel.watcherCount, 5) XCTAssertEqual(chatChannel.memberCount, 10) + XCTAssertEqual(chatChannel.messageCount, 10) XCTAssertEqual(chatChannel.reads.count, 1) XCTAssertEqual(chatChannel.reads.first?.user.id, "reader-user-id") XCTAssertEqual(chatChannel.cooldownDuration, 30) @@ -534,6 +538,7 @@ final class ChannelPayload_Tests: XCTestCase { isHidden: nil, members: nil, memberCount: 0, + messageCount: nil, team: nil, cooldownDuration: 0 ) @@ -582,6 +587,7 @@ final class ChannelPayload_Tests: XCTestCase { XCTAssertEqual(chatChannel.unreadCount, .noUnread) XCTAssertEqual(chatChannel.watcherCount, 0) XCTAssertEqual(chatChannel.memberCount, 0) + XCTAssertEqual(chatChannel.messageCount, nil) XCTAssertTrue(chatChannel.reads.isEmpty) XCTAssertEqual(chatChannel.cooldownDuration, 0) XCTAssertEqual(chatChannel.extraData, [:]) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift index 77fa9e84d09..f8d922f315e 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 { isHidden: false, members: users.map { MemberPayload.dummy(user: $0) }, memberCount: users.count, + messageCount: messageCount, team: .unique, cooldownDuration: 20 ) diff --git a/Tests/StreamChatTests/APIClient/StreamAttachmentUploader_Tests.swift b/Tests/StreamChatTests/APIClient/StreamAttachmentUploader_Tests.swift index e97d2450958..bffbba4cb85 100644 --- a/Tests/StreamChatTests/APIClient/StreamAttachmentUploader_Tests.swift +++ b/Tests/StreamChatTests/APIClient/StreamAttachmentUploader_Tests.swift @@ -61,4 +61,54 @@ final class StreamAttachmentUploader_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) } + + func test_standaloneUpload_whenSuccessful() { + let expUploadComplete = expectation(description: "should complete upload attachment") + let expProgressCalled = expectation(description: "should call progress closure") + let expectedUrl = URL.localYodaImage + let expectedProgress: Double = 20 + + let data = try! Data(contentsOf: expectedUrl) + let mockedAttachment = StreamAttachment.mock(payload: data) + let mockProgress: ((Double) -> Void) = { + XCTAssertEqual($0, expectedProgress) + expProgressCalled.fulfill() + } + + let cdnClient = CDNClient_Spy() + cdnClient.uploadAttachmentResult = .success(expectedUrl) + cdnClient.uploadAttachmentProgress = expectedProgress + + let sut = StreamAttachmentUploader(cdnClient: cdnClient) + sut.uploadStandaloneAttachment(mockedAttachment, progress: mockProgress) { result in + let uploadedAttachment = try? result.get() + XCTAssertEqual(uploadedAttachment?.fileURL, expectedUrl) + expUploadComplete.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + } + + func test_standaloneUpload_whenError() { + let exp = expectation(description: "should complete upload attachment") + + let expectedUrl = URL.localYodaImage + let data = try! Data(contentsOf: expectedUrl) + let mockedAttachment = StreamAttachment.mock(payload: data) + + let expectedError = ClientError("Some Error") + let cdnClient = CDNClient_Spy() + cdnClient.uploadAttachmentResult = .failure(expectedError) + + let sut = StreamAttachmentUploader(cdnClient: cdnClient) + sut.uploadStandaloneAttachment( + mockedAttachment, + progress: nil + ) { result in + XCTAssertEqual(result.error, expectedError) + exp.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + } } diff --git a/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift b/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift index 559287ed4be..3016db27094 100644 --- a/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift +++ b/Tests/StreamChatTests/APIClient/StreamCDNClient_Tests.swift @@ -39,6 +39,39 @@ final class StreamCDNClient_Tests: XCTestCase { // Check the encoder is called with the correct endpoint XCTAssertEqual(builder.encoder.encodeRequest_endpoints.first, AnyEndpoint(testEndpoint)) } + + func test_standaloneUploadFileEncoderIsCalledWithEndpoint() 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) + + let payload = try Data(contentsOf: .localYodaImage) + + // Create a test endpoint + let testEndpoint: Endpoint = .uploadAttachment(type: .image) + + let uploadingState = AttachmentUploadingState( + localFileURL: .localYodaImage, + state: .pendingUpload, + file: .init(type: .png, size: 120, mimeType: "image/png") + ) + + // Simulate file uploading + client.uploadStandaloneAttachment( + .mock( + payload: payload, + uploadingState: uploadingState + ), + progress: nil, + completion: { (_: Result) in } + ) + + // Check the encoder is called with the correct endpoint + XCTAssertEqual(builder.encoder.encodeRequest_endpoints.first, AnyEndpoint(testEndpoint)) + } func test_uploadFileEncoderFailingToEncode() throws { let builder = TestBuilder() @@ -47,6 +80,36 @@ final class StreamCDNClient_Tests: XCTestCase { let testError = TestError() builder.encoder.encodeRequest = .failure(testError) + let payload = try Data(contentsOf: .localYodaImage) + + let uploadingState = AttachmentUploadingState( + localFileURL: .localYodaImage, + state: .pendingUpload, + file: .init(type: .png, size: 120, mimeType: "image/png") + ) + + // Create a request and assert the result is failure + let result: Result = try waitFor { + client.uploadStandaloneAttachment( + .mock( + payload: payload, + uploadingState: uploadingState + ), + progress: nil, + completion: $0 + ) + } + + XCTAssertEqual(result.error as? TestError, testError) + } + + func test_uploadStandaloneFileEncoderFailingToEncode() throws { + let builder = TestBuilder() + let client = builder.make() + // Setup mock encoder response to fail with `testError` + let testError = TestError() + builder.encoder.encodeRequest = .failure(testError) + // Create a request and assert the result is failure let result: Result = try waitFor { client.uploadAttachment( @@ -106,6 +169,50 @@ final class StreamCDNClient_Tests: XCTestCase { // Check the outgoing data is from the decoder XCTAssertEqual(try result.get().fileURL, payload.fileURL) } + + func test_standaloneUploadFileSuccess() throws { + let builder = TestBuilder() + let decoder = builder.decoder + 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 + let url = URL.unique() + let mockResponseData = try JSONEncoder.stream.encode(["file": url]) + URLProtocol_Mock.mockResponse(request: testRequest, statusCode: 234, responseBody: mockResponseData) + + let response = FileUploadPayload(fileURL: .unique(), thumbURL: .unique()) + decoder.decodeRequestResponse = .success(response) + + let payload = try Data(contentsOf: .localYodaImage) + + let uploadingState = AttachmentUploadingState( + localFileURL: .localYodaImage, + state: .pendingUpload, + file: .init(type: .png, size: 120, mimeType: "image/png") + ) + + // Create a request and assert the result is failure + let result: Result = try waitFor { + client.uploadStandaloneAttachment( + .mock( + payload: payload, + uploadingState: uploadingState + ), + progress: nil, + completion: $0 + ) + } + + // Check the incoming data to the encoder is the URLResponse and data from the network + XCTAssertEqual(decoder.decodeRequestResponse_data, mockResponseData) + XCTAssertEqual(decoder.decodeRequestResponse_response?.statusCode, 234) + + XCTAssertEqual(try result.get().fileURL, response.fileURL) + } func test_uploadFileFailure() throws { let builder = TestBuilder() @@ -151,6 +258,56 @@ final class StreamCDNClient_Tests: XCTestCase { // Check the outgoing data is from the decoder XCTAssertEqual(result.error as? TestError, encoderError) } + + func test_standaloneUploadFileFailure() throws { + let builder = TestBuilder() + let client = builder.make() + let decoder = builder.decoder + + // 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) + let encoderError = TestError() + + // Set up a mock network response from the request + URLProtocol_Mock.mockResponse(request: testRequest, statusCode: 444, error: networkError) + + // Set up a decoder response to return `encoderError` + decoder.decodeRequestResponse = .failure(encoderError) + + let payload = try Data(contentsOf: .localYodaImage) + + let uploadingState = AttachmentUploadingState( + localFileURL: .localYodaImage, + state: .pendingUpload, + file: .init(type: .png, size: 120, mimeType: "image/png") + ) + + // Create a request and wait for the completion block + let result: Result = try waitFor { + client.uploadStandaloneAttachment( + .mock( + payload: payload, + uploadingState: uploadingState + ), + progress: nil, + completion: $0 + ) + } + + // Check the incoming error to the encoder is the error from the response + XCTAssertNotNil(decoder.decodeRequestResponse_error) + + // We have to compare error codes, since iOS14 wraps network errors into `NSURLError` + // in which we cannot retrieve the wrapper error + XCTAssertEqual((decoder.decodeRequestResponse_error as NSError?)?.code, networkError.code) + + // Check the outgoing data is from the decoder + XCTAssertEqual(result.error as? TestError, encoderError) + } func test_callingUploadFile_createsNetworkRequest() throws { let builder = TestBuilder() diff --git a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift index 094c2485c17..7568c73229c 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/LivestreamChannelController_Tests.swift @@ -2299,6 +2299,183 @@ extension LivestreamChannelController_Tests { XCTAssertEqual(controller.skippedMessagesAmount, 1) XCTAssertTrue(controller.messages.isEmpty) // Message not added when paused } + + func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteFalse_marksUserMessagesAsSoftDeleted() { + // Given + let bannedUserId = UserId.unique + let otherUserId = UserId.unique + let eventCreatedAt = Date() + + // Add messages from both users + let bannedUserMessage1 = ChatMessage.mock( + id: "banned1", + cid: controller.cid!, + text: "Message from banned user 1", + author: .mock(id: bannedUserId) + ) + let bannedUserMessage2 = ChatMessage.mock( + id: "banned2", + cid: controller.cid!, + text: "Message from banned user 2", + author: .mock(id: bannedUserId) + ) + let otherUserMessage = ChatMessage.mock( + id: "other", + cid: controller.cid!, + text: "Message from other user", + author: .mock(id: otherUserId) + ) + + // Add messages to controller + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage1, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage2, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: otherUserId), + message: otherUserMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + XCTAssertEqual(controller.messages.count, 3) + XCTAssertNil(controller.messages.first { $0.id == "banned1" }?.deletedAt) + XCTAssertNil(controller.messages.first { $0.id == "banned2" }?.deletedAt) + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + + // When + let userMessagesDeletedEvent = UserMessagesDeletedEvent( + user: .mock(id: bannedUserId), + hardDelete: false, + createdAt: eventCreatedAt + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: userMessagesDeletedEvent + ) + + // Then + XCTAssertEqual(controller.messages.count, 3) // Messages still present + + // Banned user messages should be marked as deleted + XCTAssertEqual(controller.messages.first { $0.id == "banned1" }?.deletedAt, eventCreatedAt) + XCTAssertEqual(controller.messages.first { $0.id == "banned2" }?.deletedAt, eventCreatedAt) + + // Other user message should not be affected + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + } + + func test_didReceiveEvent_userMessagesDeletedEvent_hardDeleteTrue_removesUserMessages() { + // Given + let bannedUserId = UserId.unique + let otherUserId = UserId.unique + let eventCreatedAt = Date() + + // Add messages from both users + let bannedUserMessage1 = ChatMessage.mock( + id: "banned1", + cid: controller.cid!, + text: "Message from banned user 1", + author: .mock(id: bannedUserId) + ) + let bannedUserMessage2 = ChatMessage.mock( + id: "banned2", + cid: controller.cid!, + text: "Message from banned user 2", + author: .mock(id: bannedUserId) + ) + let otherUserMessage = ChatMessage.mock( + id: "other", + cid: controller.cid!, + text: "Message from other user", + author: .mock(id: otherUserId) + ) + + // Add messages to controller + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage1, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: bannedUserId), + message: bannedUserMessage2, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: MessageNewEvent( + user: .mock(id: otherUserId), + message: otherUserMessage, + channel: .mock(cid: controller.cid!), + createdAt: .unique, + watcherCount: nil, + unreadCount: nil + ) + ) + + XCTAssertEqual(controller.messages.count, 3) + XCTAssertNotNil(controller.messages.first { $0.id == "banned1" }) + XCTAssertNotNil(controller.messages.first { $0.id == "banned2" }) + XCTAssertNotNil(controller.messages.first { $0.id == "other" }) + + // When + let userMessagesDeletedEvent = UserMessagesDeletedEvent( + user: .mock(id: bannedUserId), + hardDelete: true, + createdAt: eventCreatedAt + ) + controller.eventsController( + EventsController(notificationCenter: client.eventNotificationCenter), + didReceiveEvent: userMessagesDeletedEvent + ) + + // Then + XCTAssertEqual(controller.messages.count, 1) // Only other user's message remains + + // Banned user messages should be completely removed + XCTAssertNil(controller.messages.first { $0.id == "banned1" }) + XCTAssertNil(controller.messages.first { $0.id == "banned2" }) + + // Other user message should remain unaffected + XCTAssertNotNil(controller.messages.first { $0.id == "other" }) + XCTAssertNil(controller.messages.first { $0.id == "other" }?.deletedAt) + } } // MARK: - Message CRUD Tests diff --git a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift index e8b6d9b7d82..7881aaaf752 100644 --- a/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseSession_Tests.swift @@ -121,7 +121,8 @@ final class DatabaseSession_Tests: XCTestCase { createdAt: nil, isChannelHistoryCleared: false, banReason: nil, - banExpiredAt: nil + banExpiredAt: nil, + channelMessageCount: 5 ) // Save the event payload to DB @@ -139,6 +140,7 @@ final class DatabaseSession_Tests: XCTestCase { let loadedChannel: ChatChannel = try XCTUnwrap(database.viewContext.channel(cid: channelId)?.asModel()) let message = try XCTUnwrap(loadedMessage) XCTAssert(loadedChannel.latestMessages.contains(message)) + XCTAssertEqual(loadedChannel.messageCount, 5) } func test_deleteMessage() throws { diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index 79a9a1af8f3..b8265005626 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -343,6 +343,65 @@ final class ChannelList_Tests: XCTestCase { cancellable.cancel() } + func test_updatingChannels_whenBlockedAndUnblockedWithHiddenQuery() async throws { + await setUpChannelList( + usesMockedChannelUpdater: false, + filter: .and([ + .containMembers(userIds: [memberId]), + .or([ + .and([.equal(.blocked, to: false), .equal(.hidden, to: false)]), + .and([.equal(.blocked, to: true), .equal(.hidden, to: true)]) + ]) + ]) + ) + + // Get initial batch of channels where 2 are blocked, 3 are not + let blockedOffsets = Set([1, 3]) + let hiddenOffsets = Set([1, 3]) + let firstChannelListPayload = makeMatchingChannelListPayload( + channelCount: 5, + createdAtOffset: 0, + blocked: { _, offset in blockedOffsets.contains(offset) }, + hidden: { _, offset in hiddenOffsets.contains(offset) } + ) + env.client.mockAPIClient.test_mockResponseResult(.success(firstChannelListPayload)) + try await channelList.get() + await XCTAssertEqual(5, channelList.state.channels.count) + await XCTAssertEqual(2, channelList.state.channels.filter(\.isBlocked).count) + await XCTAssertEqual(2, channelList.state.channels.filter(\.isHidden).count) + + // Send an event which does not include blocked and hidden states, but contains channel info + // Channel gets saved to DB and should not change blocked and hidden states. + let secondChannel = firstChannelListPayload.channels[1].channel + let channelPayloadWithoutBlockedAndHidden = ChannelDetailPayload.dummy( + cid: secondChannel.cid, + isBlocked: nil, + isHidden: nil, + members: secondChannel.members ?? [] + ) + let eventPayload = EventPayload( + eventType: .notificationMarkRead, + cid: channelPayloadWithoutBlockedAndHidden.cid, + user: .dummy(userId: memberId), + channel: channelPayloadWithoutBlockedAndHidden, + unreadCount: .init(channels: 0, messages: 0, threads: 0), + createdAt: Date() + ) + let notificationMarkReadEvent = try NotificationMarkReadEventDTO(from: eventPayload) + let expectation = XCTestExpectation() + env.client.eventNotificationCenter.process(notificationMarkReadEvent, postNotification: true) { + expectation.fulfill() + } + await fulfillment(of: [expectation]) + + let secondChannelDataAfterEvent = try env.client.databaseContainer.readSynchronously { session in + try XCTUnwrap(session.channel(cid: secondChannel.cid)).asModel() + } + XCTAssertEqual(true, secondChannelDataAfterEvent.isBlocked, "State did not change") + XCTAssertEqual(true, secondChannelDataAfterEvent.isHidden, "State did not change") + await XCTAssertEqual(5, channelList.state.channels.count) + } + // MARK: - Linking and Unlinking Channels func test_observingEvents_whenAddedToChannelEventReceived_thenChannelIsLinkedAndStateUpdates() async throws { @@ -487,7 +546,9 @@ final class ChannelList_Tests: XCTestCase { createdAtOffset: Int, namePrefix: String = "Name", membersCreator: ((ChannelId, Int) -> [MemberPayload])? = nil, - messagesCreator: ((ChannelId, Int) -> [MessagePayload])? = nil + messagesCreator: ((ChannelId, Int) -> [MessagePayload])? = nil, + blocked: ((ChannelId, Int) -> Bool) = { _, _ in false }, + hidden: ((ChannelId, Int) -> Bool) = { _, _ in false } ) -> ChannelListPayload { let channelPayloads = (0..